Compare commits

..
17 Commits
Author SHA1 Message Date
kavehhn 898ff6cf69 fix(docker): remove apk add and dumb-init to avoid build timeout
deploy-chabokan / deploy (push) Canceled after 0s
2026-08-30 09:49:32 +03:30
kavehhn d140de86a3 build(docker): use liara npm mirror registry
deploy-chabokan / deploy (push) Canceled after 0s
2026-08-30 09:42:11 +03:30
kavehhn 576ccf5421 feat(dashboard): add aggregated dashboard stats API, robust logout, and warehouse seeders
deploy-chabokan / deploy (push) Canceled after 0s
2026-08-30 09:29:21 +03:30
kavehhn 9b88b342ad fix(file): skip redundant MinIO round-trips for files already in target bucket
deploy-chabokan / deploy (push) Canceled after 0s
moveFromTempToMain was calling statObject/copy/remove against MinIO for
every file already attached to a property on every save, even when the
file was already in the target bucket and never touched temp. This added
a blocking network round-trip per image/document on every property save.
Now files already pointing at the target bucket are returned immediately
without hitting MinIO.
2026-08-19 08:02:10 +03:30
kavehhn e63ac1ddf4 fix(property): optimize save performance, fix 2dsphere index error and make search indexing non-blocking
deploy-chabokan / deploy (push) Canceled after 0s
2026-08-19 07:46:08 +03:30
kavehhn 30ea3aed84 feat(settings): add autosave settings API endpoints and persistence
deploy-chabokan / deploy (push) Canceled after 0s
2026-08-19 07:27:07 +03:30
kavehhn fb4a98f83c fix(date): add id and name to persianMonths definition
deploy-chabokan / deploy (push) Canceled after 0s
2026-08-19 07:17:35 +03:30
kavehhn 7d70a64cd6 fix(property): support resolving properties by propertyCode and ObjectId in CRUD
deploy-chabokan / deploy (push) Canceled after 0s
2026-08-19 07:13:29 +03:30
kavehhn 95c0532374 fix(auth/property): resolve auth token resolution, add property seeder
deploy-chabokan / deploy (push) Canceled after 0s
2026-08-19 07:08:36 +03:30
kavehhn 284594c782 feat(seeder): add one-time database seeding endpoint with status tracking
deploy-chabokan / deploy (push) Canceled after 0s
2026-08-19 06:49:53 +03:30
kavehhn 8bdd5af719 feat(property): convert registrationDate to Date object and add Jalali utilities
deploy-chabokan / deploy (push) Canceled after 0s
2026-08-19 06:45:50 +03:30
kavehhn 982b9e759f fix(s3): generate and use seaweedfs signed urls for properties and documents
deploy-chabokan / deploy (push) Canceled after 0s
2026-08-19 06:30:00 +03:30
kavehhn acb2e5d623 feat: add MongoDB search method, endpoints, and settings module
deploy-chabokan / deploy (push) Canceled after 0s
2026-08-19 05:50:14 +03:30
kavehhn 3829851566 feat(cron): add 5-minute property index cronjob with change detection
deploy-chabokan / deploy (push) Canceled after 0s
2026-08-19 05:06:19 +03:30
kavehhn 31ebafb53c feat(bucket): auto-create buckets and move files from temp to main buckets on property save
deploy-chabokan / deploy (push) Canceled after 0s
2026-08-19 04:58:14 +03:30
kavehhn dd611df7e5 fix(file): ensure full valid URLs in publicUrl and privateUrl virtual getters
deploy-chabokan / deploy (push) Canceled after 0s
2026-08-19 04:49:40 +03:30
kavehhn 61c9012912 fix(file): add file validation and robust payload parsing for bucket upload and delete
deploy-chabokan / deploy (push) Canceled after 0s
2026-08-19 04:35:42 +03:30
41 changed files with 2105 additions and 102 deletions
+5 -4
View File
@@ -5,6 +5,9 @@ WORKDIR /app
COPY package*.json ./
# Configure Liara npm mirror registry
RUN npm config set registry https://package-mirror.liara.ir/repository/npm/ --global
RUN npm ci --omit=dev --ignore-scripts && npm cache clean --force
# Stage 2: Production runner
@@ -15,8 +18,8 @@ WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
# Install dumb-init for robust signal handling (PID 1)
RUN apk add --no-cache dumb-init && mkdir -p /app/uploads && chown -R node:node /app
# Create uploads directory and set ownership for non-root node user
RUN mkdir -p /app/uploads && chown -R node:node /app
# Switch to non-root user
USER node
@@ -30,6 +33,4 @@ COPY --chown=node:node . .
# Expose backend port
EXPOSE 3000
# Run container with dumb-init
ENTRYPOINT ["/usr/bin/dumb-init", "--"]
CMD ["node", "app.js"]
+2 -1
View File
@@ -11,7 +11,7 @@ exports.getCategoryTree = catchAsync(async (req, res, next) => {
})
exports.getAllCategories = catchAsync(async (req, res, next) => {
const categories = await categoryService.getAllCategories()
const categories = await categoryService.getAllCategories(req.query)
res.status(200).json({
status: 'success',
@@ -20,6 +20,7 @@ exports.getAllCategories = catchAsync(async (req, res, next) => {
})
})
exports.createCategory = catchAsync(async (req, res, next) => {
const category = await categoryService.createCategory(req.body)
+11 -2
View File
@@ -1,10 +1,19 @@
const Category = require('./categoryModel')
const AppError = require('../../globalErrorHandler/AppError')
module.exports.getAllCategories = async () => {
return Category.find().populate('features')
module.exports.getAllCategories = async (query = {}) => {
const mongoQuery = {}
const search = (query.search || query.query || '').trim()
if (search) {
mongoQuery.$or = [
{ nativeName: { $regex: search, $options: 'i' } },
{ englishName: { $regex: search, $options: 'i' } },
]
}
return Category.find(mongoQuery).populate('features')
}
module.exports.createCategory = async (data) => {
return Category.create(data, null)
}
@@ -0,0 +1,11 @@
const catchAsync = require('../../globalErrorHandler/catchAsync')
const dashboardService = require('./dashboardService')
exports.getDashboardStats = catchAsync(async (req, res, next) => {
const stats = await dashboardService.getDashboardStats()
res.status(200).json({
status: 'success',
data: stats,
})
})
+12
View File
@@ -0,0 +1,12 @@
const express = require('express')
const dashboardController = require('./dashboardController')
const loginCheck = require('../../middlewares/loginCheck')
const router = new express.Router()
router.use(loginCheck.loginProtect)
router.get('/stats', dashboardController.getDashboardStats)
router.get('/summary', dashboardController.getDashboardStats)
module.exports = router
+232
View File
@@ -0,0 +1,232 @@
const Property = require('../property/propertyModel')
const Category = require('../category/categoryModel')
const PropertyStatus = require('../propertyStatus/propertyStatusModel')
const Warehouse = require('../warehouse/warehouseModel')
const WarehouseItem = require('../warehouseItem/warehouseItemModel')
const WarehouseItemTransaction = require('../warehouseItem/warehouseItemTransactionModel')
const User = require('../user/userModel')
const Role = require('../role/roleModel')
const { getSignedUrl } = require('../file/fileService')
/**
* Aggregates live dashboard statistics across properties, warehouses, inventory, and users
*/
const getDashboardStats = async () => {
// 1. Overview counts
const [
totalProperties,
totalAreaResult,
totalWarehouseItems,
totalWarehouses,
totalWarehouseTransactions,
totalUsers,
totalCategories,
totalRoles,
] = await Promise.all([
Property.countDocuments({ isActive: { $ne: false } }),
Property.aggregate([
{ $match: { isActive: { $ne: false }, area: { $gt: 0 } } },
{ $group: { _id: null, totalArea: { $sum: '$area' } } },
]),
WarehouseItem.countDocuments({ isActive: { $ne: false } }),
Warehouse.countDocuments(),
WarehouseItemTransaction.countDocuments(),
User.countDocuments({ isActive: { $ne: false } }),
Category.countDocuments(),
Role.countDocuments(),
])
const totalArea = totalAreaResult?.[0]?.totalArea || 0
// 2. Properties grouped by Category
const propertiesByCategoryRaw = await Property.aggregate([
{ $match: { isActive: { $ne: false } } },
{
$group: {
_id: '$category',
count: { $sum: 1 },
totalArea: { $sum: { $ifNull: ['$area', 0] } },
},
},
{
$lookup: {
from: 'categories',
localField: '_id',
foreignField: '_id',
as: 'categoryDoc',
},
},
{ $unwind: { path: '$categoryDoc', preserveNullAndEmptyArrays: true } },
{ $sort: { count: -1 } },
])
const propertiesByCategory = propertiesByCategoryRaw.map((item) => ({
categoryId: item._id,
name: item.categoryDoc?.nativeName || item.categoryDoc?.englishName || 'بدون دسته‌بندی',
englishName: item.categoryDoc?.englishName || 'Uncategorized',
count: item.count,
totalArea: item.totalArea || 0,
percentage: totalProperties > 0 ? Math.round((item.count / totalProperties) * 100) : 0,
}))
// 3. Properties grouped by Status
const propertiesByStatusRaw = await Property.aggregate([
{ $match: { isActive: { $ne: false } } },
{
$group: {
_id: '$status',
count: { $sum: 1 },
},
},
{
$lookup: {
from: 'propertystatuses',
localField: '_id',
foreignField: '_id',
as: 'statusDoc',
},
},
{ $unwind: { path: '$statusDoc', preserveNullAndEmptyArrays: true } },
{ $sort: { count: -1 } },
])
const propertiesByStatus = propertiesByStatusRaw.map((item) => ({
statusId: item._id,
name: item.statusDoc?.nativeName || item.statusDoc?.englishName || 'نامشخص',
englishName: item.statusDoc?.englishName || 'Unknown',
color: item.statusDoc?.color || '#3b82f6',
count: item.count,
percentage: totalProperties > 0 ? Math.round((item.count / totalProperties) * 100) : 0,
}))
// 4. Warehouse transactions summary (IN vs OUT)
const warehouseFlowRaw = await WarehouseItemTransaction.aggregate([
{
$group: {
_id: '$type',
count: { $sum: 1 },
totalQuantity: { $sum: '$quantity' },
},
},
])
const warehouseFlow = {
inCount: warehouseFlowRaw.find((f) => f._id === 'IN')?.count || 0,
outCount: warehouseFlowRaw.find((f) => f._id === 'OUT')?.count || 0,
inQuantity: warehouseFlowRaw.find((f) => f._id === 'IN')?.totalQuantity || 0,
outQuantity: warehouseFlowRaw.find((f) => f._id === 'OUT')?.totalQuantity || 0,
}
// 5. Recent registered properties (Last 6)
const recentPropertiesRaw = await Property.find({ isActive: { $ne: false } })
.sort({ createdAt: -1 })
.limit(6)
.populate('category', 'nativeName englishName')
.populate('status', 'nativeName englishName color')
.lean()
const recentProperties = await Promise.all(
recentPropertiesRaw.map(async (prop) => {
let thumb = null
if (prop.mainImageUrls && prop.mainImageUrls.length > 0) {
thumb = await getSignedUrl(prop.mainImageUrls[0], 'properties').catch(() => null)
}
return {
_id: prop._id,
propertyCode: prop.propertyCode || prop._id.toString().slice(-6),
name: prop.name || 'ملک بدون عنوان',
category: prop.category?.nativeName || 'عمومی',
status: prop.status?.nativeName || 'عادی',
statusColor: prop.status?.color || '#3b82f6',
ownerName: prop.ownerName || '—',
area: prop.area || 0,
dong: prop.dong || 6,
imageUrl: thumb || 'https://cloud.mbmanager.ir/images/item-no-image.png',
registrationPlate: prop.registrationPlate || '—',
createdAt: prop.createdAt,
}
})
)
// 6. Recent warehouse transactions (Last 6)
const recentWarehouseTransactionsRaw = await WarehouseItemTransaction.find()
.sort({ createdAt: -1 })
.limit(6)
.populate('item', 'nameFa nameEn code unitFa')
.populate('warehouse', 'name location')
.lean()
const recentWarehouseTransactions = recentWarehouseTransactionsRaw.map((tx) => ({
_id: tx._id,
type: tx.type, // 'IN' | 'OUT'
quantity: tx.quantity,
unit: tx.unit || tx.item?.unitFa || 'عدد',
itemName: tx.item?.nameFa || 'کالای نامشخص',
itemCode: tx.item?.code || '—',
warehouseName: tx.warehouse?.name || 'انبار مرکزی',
reason: tx.reason || (tx.type === 'IN' ? 'ورود به انبار' : 'خروج از انبار'),
actorName: tx.actorName || 'کاربر سیستم',
date: tx.date || tx.createdAt,
}))
// 7. Monthly Property & Warehouse Trend (Last 6 months)
const sixMonthsAgo = new Date()
sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 5)
sixMonthsAgo.setDate(1)
sixMonthsAgo.setHours(0, 0, 0, 0)
const monthlyPropertiesRaw = await Property.aggregate([
{ $match: { createdAt: { $gte: sixMonthsAgo }, isActive: { $ne: false } } },
{
$group: {
_id: {
year: { $year: '$createdAt' },
month: { $month: '$createdAt' },
},
count: { $sum: 1 },
},
},
{ $sort: { '_id.year': 1, '_id.month': 1 } },
])
const monthlyWarehouseRaw = await WarehouseItemTransaction.aggregate([
{ $match: { createdAt: { $gte: sixMonthsAgo } } },
{
$group: {
_id: {
year: { $year: '$createdAt' },
month: { $month: '$createdAt' },
type: '$type',
},
count: { $sum: 1 },
},
},
{ $sort: { '_id.year': 1, '_id.month': 1 } },
])
return {
overview: {
totalProperties,
totalArea,
totalWarehouseItems,
totalWarehouses,
totalWarehouseTransactions,
totalUsers,
totalCategories,
totalRoles,
},
propertiesByCategory,
propertiesByStatus,
warehouseFlow,
recentProperties,
recentWarehouseTransactions,
monthlyTrend: {
properties: monthlyPropertiesRaw,
warehouse: monthlyWarehouseRaw,
},
}
}
module.exports = {
getDashboardStats,
}
+10 -1
View File
@@ -2,6 +2,12 @@ const catchAsync = require('../../globalErrorHandler/catchAsync')
const fileService = require('./fileService')
exports.bucketSingleUpload = catchAsync(async (req, res, next) => {
if (!req.file) {
return res.status(400).json({
status: 'fail',
message: 'No file uploaded'
})
}
const fileData = await fileService.bucketSingleUpload(req.file, req.params.bucketName)
res.status(200).json({
@@ -71,7 +77,10 @@ exports.getImage = catchAsync(async (req, res, next) => {
});
exports.bucketDeleteFile = catchAsync(async (req, res, next) => {
const { fileName } = await fileService.bucketDeleteFile(req.body, req.params.bucketName)
const rawTarget = typeof req.body === 'object' && req.body !== null
? (req.body.fileName || req.body.url || req.body.data || '')
: (req.body || '');
const { fileName } = await fileService.bucketDeleteFile(rawTarget, req.params.bucketName)
res.status(200).json({
status: 'success',
+9 -2
View File
@@ -25,11 +25,18 @@ const fileSchema = new mongoose.Schema(
)
fileSchema.virtual('publicUrl').get(function () {
return this.fileEndpoint + '/' + this.bucketName + '/' + this.fileName
const protocol = process.env.AWS_USE_SSL === 'true' ? 'https://' : 'http://'
let endpoint = this.fileEndpoint || process.env.AWS_ENDPOINT || 'localhost'
if (!endpoint.startsWith('http://') && !endpoint.startsWith('https://')) {
const hostPort = process.env.AWS_PORT ? `${endpoint}:${process.env.AWS_PORT}` : endpoint
endpoint = `${protocol}${hostPort}`
}
return `${endpoint.replace(/\/$/, '')}/${this.bucketName}/${this.fileName}`
})
fileSchema.virtual('privateUrl').get(function () {
return process.env.API_URL + '/api/files/get-image/' + this.bucketName + '/' + this.fileName
const apiUrl = (process.env.API_URL || '').replace(/\/$/, '')
return `${apiUrl}/api/files/get-image/${this.bucketName}/${this.fileName}`
})
const File = mongoose.model('file', fileSchema)
+115 -18
View File
@@ -52,14 +52,26 @@ const bucketMultiUpload = async (files, bucketName) => {
}
const bucketDeleteFile = async (fileName, bucketName) => {
let exportedFileName = getLastUrlSegment(fileName)
await deleteObject(exportedFileName, bucketName)
await deleteFile(exportedFileName)
let raw = typeof fileName === 'object' && fileName !== null
? (fileName.fileName || fileName.url || fileName.name || '')
: fileName
let exportedFileName = getLastUrlSegment(String(raw || ''))
if (exportedFileName) {
await deleteObject(exportedFileName, bucketName).catch((err) => {
console.error('Error deleting object from MinIO bucket:', err)
})
await deleteFile(exportedFileName).catch((err) => {
console.error('Error deleting file record from DB:', err)
})
}
return { fileName }
return { fileName: exportedFileName }
}
const getLastUrlSegment = (url) => {
if (!url || typeof url !== 'string') {
return ''
}
if (!url.includes('/')) {
return url
}
@@ -106,29 +118,114 @@ const bucketGetFiles = async (bucketName) => {
return listOfObjects(bucketName)
}
const getSignedUrl = async (fileName, bucketName) => {
if (!fileName) {
return
const parseFileAndBucket = (input, defaultBucket = 'properties') => {
if (!input) return { fileName: '', bucketName: defaultBucket }
const raw = typeof input === 'object' && input !== null
? (input.fileName || input.name || input.url || '')
: String(input || '')
const clean = raw.split('?')[0]
const match = clean.match(/(?:^|\/)(properties|documents|temp|belongings|belonging-images|belonging-documents|property-images|property-documents)\/([^/]+)$/i)
if (match) {
let b = match[1]
if (b === 'property-images' || b === 'belonging-images') b = 'properties'
if (b === 'property-documents' || b === 'belonging-documents') b = 'documents'
return { bucketName: b, fileName: decodeURIComponent(match[2]) }
}
return signedUrl(bucketName, fileName)
const parts = clean.split('/')
const fileName = decodeURIComponent(parts.pop() || '')
return { bucketName: defaultBucket, fileName }
}
const getMultipleSignedUrls = async (fileNameArray, bucketName) => {
const urls = []
for (let fileName of fileNameArray) {
urls.push({ url: await getSignedUrl(fileName, bucketName), fileName: fileName})
const getSignedUrl = async (input, defaultBucket = 'properties') => {
if (!input) {
return null
}
return urls
const { fileName, bucketName } = parseFileAndBucket(input, defaultBucket)
if (!fileName) {
return null
}
try {
return await signedUrl(bucketName, fileName)
} catch (err) {
console.error(`Error generating signed URL for ${bucketName}/${fileName}:`, err.message)
return null
}
}
const getMultipleSignedUrls = async (fileNameArray, defaultBucket = 'properties') => {
if (!fileNameArray || !Array.isArray(fileNameArray)) {
return []
}
const results = await Promise.all(
fileNameArray.map(async (item) => {
const { fileName, bucketName } = parseFileAndBucket(item, defaultBucket)
if (!fileName) return null
const url = await getSignedUrl(item, bucketName)
return url ? { url, fileName, bucketName } : null
})
)
return results.filter(Boolean)
}
const moveFromTempToMain = async (files, targetBucket) => {
const tempBucket = 'temp'
for (const file of files) {
await copyObject(targetBucket, file, `${tempBucket}/${file}`)
await removeObject(tempBucket, file)
if (!files || !Array.isArray(files) || files.length === 0) {
return []
}
const { minioClient } = require('../../utils/upload/bucket')
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)
if (!fileName) {
return null
}
// Already pointing at the target bucket (e.g. re-submitted unchanged on every
// property save) - skip the MinIO round-trips entirely, nothing to move.
if (rawStr.includes(`/${targetBucket}/`) && !rawStr.includes(`/${tempBucket}/`)) {
return rawStr.split('?')[0]
}
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(() => {})
}
// 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.filter(Boolean)
}
const getImage = async (bucketName, fileName) => {
+13
View File
@@ -511,6 +511,19 @@ const permissionsList = [
key: 'admindashboard',
category: 'سیستم و داشبورد',
},
{
englishName: 'View System Settings',
nativeName: 'مشاهده تنظیمات سیستم',
key: 'getsettings',
category: 'تنظیمات سیستم',
},
{
englishName: 'Update System Settings',
nativeName: 'ویرایش تنظیمات سیستم',
key: 'updatesettings',
category: 'تنظیمات سیستم',
},
]
module.exports = permissionsList
+2 -2
View File
@@ -68,11 +68,11 @@ const belongingSchema = new mongoose.Schema(
belongingSchema.plugin(AutoIncrement, { inc_field: 'belongingCode' })
belongingSchema.methods.getDocumentsSignedUrls = function () {
return getMultipleSignedUrls(this.documentUrls, 'belonging-documents')
return getMultipleSignedUrls(this.documentUrls, 'documents')
}
belongingSchema.methods.getMainImagedSignedUrl = function () {
return getMultipleSignedUrls(this.mainImageUrls, 'belonging-images')
return getMultipleSignedUrls(this.mainImageUrls, 'properties')
}
belongingSchema.index({ location: '2dsphere' }) // Enable geospatial queries
+7 -1
View File
@@ -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)
}
})
}
@@ -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)
}
})
}
+17 -2
View File
@@ -3,7 +3,7 @@ const propertyService = require('./propertyService')
const { eventEmitter } = require('../../events/eventEmitter')
exports.searchProperties = catchAsync(async (req, res, next) => {
const { facets, belongings, totalCount, pagesCount } = await propertyService.searchProperties(req.query)
const { facets, belongings, totalCount, pagesCount, searchEngine } = await propertyService.searchProperties(req.query)
res.status(200).json({
status: 'success',
@@ -11,9 +11,24 @@ exports.searchProperties = catchAsync(async (req, res, next) => {
properties: belongings,
totalCount,
pagesCount,
searchEngine,
})
})
exports.searchPropertiesMongo = catchAsync(async (req, res, next) => {
const { facets, belongings, totalCount, pagesCount, searchEngine } = await propertyService.searchPropertiesMongo(req.query)
res.status(200).json({
status: 'success',
facets,
properties: belongings,
totalCount,
pagesCount,
searchEngine: searchEngine || 'mongo',
})
})
exports.indexAllProperties = catchAsync(async (req, res, next) => {
const { indexedCount } = await propertyService.indexAllProperties()
@@ -62,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',
+21 -4
View File
@@ -57,7 +57,7 @@ const propertySchema = new mongoose.Schema(
serialNumber: String, // شماره سریال چاپی سند
provinceRegistry: String,
localRegistryOffice: String,
registrationDate: String, // تاریخ ثبت سند
registrationDate: Date, // تاریخ ثبت سند (MongoDB Date object)
// Owner Specifications (مشخصات مالک)
ownerName: String,
@@ -73,6 +73,7 @@ const propertySchema = new mongoose.Schema(
ownershipDocs: String,
// Property Specifications (مشخصات ملک)
area: Number, // مساحت عرصه (متر مربع)
registrationPlate: String,
mainNumber: String,
subNumber: String,
@@ -115,15 +116,31 @@ 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, 'property-documents')
return getMultipleSignedUrls(this.documentUrls, 'documents')
}
propertySchema.methods.getMainImagedSignedUrl = function () {
return getMultipleSignedUrls(this.mainImageUrls, 'property-images')
return getMultipleSignedUrls(this.mainImageUrls, 'properties')
}
propertySchema.index({ location: '2dsphere' })
propertySchema.index({ location: '2dsphere' }, { sparse: true })
const Property = mongoose.model('Property', propertySchema)
+2
View File
@@ -16,6 +16,8 @@ router.post('/admin/index-all', perm.requires('indexproperties', 'indexbelonging
router.post('/admin/index-one/:propertyId', perm.requires('indexproperties', 'indexbelongings'), propertyController.indexOneProperty)
router.get('/admin/search', perm.requires('searchproperties', 'searchbelongings'), propertyController.searchProperties)
router.get('/admin/search-mongo', perm.requires('searchproperties', 'searchbelongings'), propertyController.searchPropertiesMongo)
router.get('/admin/get-one/:propertyId', perm.requires('getaproperty', 'getabelonging'), propertyController.getProperty)
+153
View File
@@ -0,0 +1,153 @@
const Property = require('./propertyModel')
const Land = require('./landModel')
const Building = require('./buildingModel')
const Category = require('../category/categoryModel')
const PropertyStatus = require('../propertyStatus/propertyStatusModel')
const chalk = require('chalk')
const runSeeder = async () => {
const buildingCategory = await Category.findOne({ englishName: 'Building' })
const lotCategory = await Category.findOne({ englishName: 'Lot' })
const availableStatus = await PropertyStatus.findOne({ englishName: 'available' })
const inReviewStatus = await PropertyStatus.findOne({ englishName: 'in_review' })
const reservedStatus = await PropertyStatus.findOne({ englishName: 'reserved' })
const sampleProperties = [
{
name: 'مجتمع اداری تجاری نگین الهیه',
propertyType: 'Building',
category: buildingCategory?._id,
status: availableStatus?._id,
nationalGeoCode: '537123395123439',
description: 'مجتمع اداری ۱۰ طبقه با دسترسی عالی به بزرگراه صدر و مترو',
area: 1500,
dong: 6,
ownerName: 'شرکت سرمایه‌گذاری پارس',
registrationPlate: '۱۲۳۴/۵۶',
uniqueId: '140201123456789012',
serialNumber: 'A/987654',
zoningType: 'تجاری - اداری',
address: 'تهران، الهیه، خیابان فرشته، پلاک ۱۲',
postalCode: '1965834211',
location: {
type: 'Point',
coordinates: [51.4235, 35.7952], // [lng, lat]
},
tags: ['اداری', 'الهیه', 'مدرن', 'سند تک‌برگ'],
isActive: true,
},
{
name: 'زمین ویلایی شهرک غرب فاز ۴',
propertyType: 'Land',
category: lotCategory?._id,
status: availableStatus?._id,
nationalGeoCode: '536987394852139',
description: 'زمین دو کله با بر ۲۵ متر و دستور نقشه ۵ طبقه مسکونی',
area: 600,
dong: 6,
ownerName: 'محسن رضایی',
registrationPlate: '۴۵۶۷/۸۹',
uniqueId: '140202234567890123',
serialNumber: 'B/123456',
zoningType: 'مسکونی',
address: 'تهران، شهرک غرب، فاز ۴، خیابان فلامک شمالی',
postalCode: '1467893214',
location: {
type: 'Point',
coordinates: [51.3712, 35.7725],
},
tags: ['زمین', 'شهرک غرب', 'دو کله'],
isActive: true,
},
{
name: 'برج مسکونی یاس نیاوران',
propertyType: 'Building',
category: buildingCategory?._id,
status: reservedStatus?._id || availableStatus?._id,
nationalGeoCode: '539145395412039',
description: 'واحد ۳۰۰ متری طبقه ۸ با ویوی ابدی کاخ نیاوران و ۳ پارکینگ سندی',
area: 300,
dong: 6,
ownerName: 'سارا صادقی',
registrationPlate: '۷۸۹۰/۱۲',
uniqueId: '140203345678901234',
serialNumber: 'C/654321',
zoningType: 'مسکونی',
address: 'تهران، نیاوران، خیابان باهنر، کوچه یاس',
postalCode: '1978945612',
location: {
type: 'Point',
coordinates: [51.4678, 35.8156],
},
tags: ['لوکس', 'نیاوران', 'پنت‌هاوس'],
isActive: true,
},
{
name: 'باغ ویلای قصرالدشت شیراز',
propertyType: 'Land',
category: lotCategory?._id,
status: availableStatus?._id,
nationalGeoCode: '645123328456139',
description: 'باغ ۲۰۰۰ متری با درختان کهنسال و عمارت دوبلکس ۴۰۰ متری',
area: 2000,
dong: 6,
ownerName: 'علیرضا زندی',
registrationPlate: '۳۲۱۴/۵۶',
uniqueId: '140204456789012345',
serialNumber: 'D/789123',
zoningType: 'باغ ویلا',
address: 'شیراز، قصرالدشت، خیابان وکلا، کوچه ۱۲',
postalCode: '7198765432',
location: {
type: 'Point',
coordinates: [52.4823, 29.6412],
},
tags: ['شیراز', 'باغ', 'قصرالدشت'],
isActive: true,
},
{
name: 'ملک تجاری چهارباغ عباسی اصفهان',
propertyType: 'Building',
category: buildingCategory?._id,
status: inReviewStatus?._id || availableStatus?._id,
nationalGeoCode: '584123361456239',
description: 'مغازه تجاری دو دهنه بر خیابان اصلی چهارباغ با سند شش دانگ ملکیت',
area: 180,
dong: 6,
ownerName: 'محمد مهدی اصفهانی',
registrationPlate: '۹۶۳۲/۱۴',
uniqueId: '140205567890123456',
serialNumber: 'E/456789',
zoningType: 'تجاری',
address: 'اصفهان، خیابان چهارباغ عباسی، جنب سینما سپاهان',
postalCode: '8145678901',
location: {
type: 'Point',
coordinates: [51.6685, 32.6546],
},
tags: ['اصفهان', 'تجاری', 'چهارباغ'],
isActive: true,
},
]
await Property.deleteMany({})
console.log(chalk.red('Deleted all property records.'))
for (const propData of sampleProperties) {
if (propData.propertyType === 'Building') {
await Building.create(propData)
} else if (propData.propertyType === 'Land') {
await Land.create(propData)
} else {
await Property.create(propData)
}
}
console.log(
chalk.green.bold(
`PROPERTIES: Created ${sampleProperties.length} sample property(ies)`
)
)
}
module.exports = { runSeeder }
+290 -37
View File
@@ -4,40 +4,157 @@ const Building = require('./buildingModel')
const AppError = require('../../globalErrorHandler/AppError')
const { client } = require('../../setup/typesense')
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.propertyType === 'Land' || data.propertyType === 'زمین') {
return Land.create(data)
} else if (data.propertyType === 'Building' || data.propertyType === 'ساختمان') {
return Building.create(data)
const cleanData = sanitizePropertyData(data)
if (cleanData.mainImageUrls && Array.isArray(cleanData.mainImageUrls) && cleanData.mainImageUrls.length > 0) {
cleanData.mainImageUrls = await moveFromTempToMain(cleanData.mainImageUrls, 'properties')
}
return Property.create(data)
if (cleanData.documentUrls && Array.isArray(cleanData.documentUrls) && cleanData.documentUrls.length > 0) {
cleanData.documentUrls = await moveFromTempToMain(cleanData.documentUrls, 'documents')
}
if (cleanData.propertyType === 'Land' || cleanData.propertyType === 'زمین') {
return Land.create(cleanData)
} else if (cleanData.propertyType === 'Building' || cleanData.propertyType === 'ساختمان') {
return Building.create(cleanData)
}
return Property.create(cleanData)
}
const resolvePropertyFilter = (propertyId) => {
if (!propertyId) return { _id: null }
if (mongoose.Types.ObjectId.isValid(propertyId)) {
return { _id: propertyId }
}
if (!isNaN(propertyId) && String(propertyId).trim() !== '') {
return { propertyCode: Number(propertyId) }
}
return { _id: propertyId }
}
const getProperty = async (propertyId) => {
let property = await Property.findById(propertyId, null, null).populate('category').populate('status')
const filter = resolvePropertyFilter(propertyId)
let property = await Property.findOne(filter, null, null).populate('category').populate('status')
if (!property && !filter._id && mongoose.Types.ObjectId.isValid(propertyId)) {
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()
let documentUrls = []
let mainImageUrls = []
try {
if (typeof property.getDocumentsSignedUrls === 'function') {
documentUrls = await property.getDocumentsSignedUrls()
}
if (typeof property.getMainImagedSignedUrl === 'function') {
mainImageUrls = await property.getMainImagedSignedUrl()
}
} catch (e) {
console.warn('Error fetching signed URLs for property:', e.message)
}
property = JSON.parse(JSON.stringify(property))
property.signedDocumentUrls = documentUrls
property.signedMainImageUrls = mainImageUrls
property.signedDocumentUrls = documentUrls || []
property.signedMainImageUrls = mainImageUrls || []
return property
}
const updateProperty = async (propertyId, data) => {
const property = await Property.findOneAndUpdate({ _id: propertyId }, data, { new: true })
const cleanData = sanitizePropertyData(data)
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, cleanData, { new: true })
if (!property) {
throw new AppError('NoPropertyFound', 404, {})
@@ -47,7 +164,8 @@ const updateProperty = async (propertyId, data) => {
}
const deleteProperty = async (propertyId) => {
const property = await Property.findByIdAndUpdate(propertyId, { isActive: false }, { new: true })
const filter = resolvePropertyFilter(propertyId)
const property = await Property.findOneAndUpdate(filter, { isActive: false }, { new: true })
if (!property) {
throw new AppError('NoPropertyFound', 404, {})
@@ -75,15 +193,141 @@ const ensureCollectionExists = async (forceRecreate = false) => {
}
}
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 = await Promise.all(properties.map(async (prop) => {
const combinedTags = Array.isArray(prop.tags) ? prop.tags.join(' ') : (prop.tags || '')
let signedThumb = null
if (prop.mainImageUrls && prop.mainImageUrls.length > 0) {
signedThumb = await getSignedUrl(prop.mainImageUrls[0], 'properties').catch(() => null)
}
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: signedThumb || '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 || '',
registrationDate: prop.registrationDate,
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) => {
await ensureCollectionExists(false)
inputQuery = updateInputQuery(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)
}
const tsFeatures = new TsApiFeatures(client, 'properties', inputQuery).filter().facet().sort().limitFields().paginate().search()
try {
await ensureCollectionExists(false)
const updatedQuery = updateInputQuery({ ...inputQuery }, {})
let { facets, items, totalCount, pagesCount } = await tsFeatures.execute()
const tsFeatures = new TsApiFeatures(client, 'properties', updatedQuery).filter().facet().sort().limitFields().paginate().search()
return { facets, belongings: items, totalCount, pagesCount }
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) => {
@@ -96,6 +340,7 @@ const updateInputQuery = (inputQuery, extraQueries) => {
return inputQuery
}
const indexAllProperties = async () => {
const batchSize = 20
const count = await Property.countDocuments({ isActive: true }, null)
@@ -144,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 = () => {
@@ -198,5 +449,7 @@ module.exports = {
createProperty,
deleteProperty,
searchProperties,
searchPropertiesMongo,
indexOneProperty,
}
+2 -1
View File
@@ -4,7 +4,7 @@ const Role = require('./roleModel')
const roleService = require('./roleService')
exports.getAllRoles = catchAsync(async (req, res, next) => {
const roles = await roleService.getAllRoles()
const roles = await roleService.getAllRoles(req.query)
res.status(200).json({
status: 'success',
@@ -13,6 +13,7 @@ exports.getAllRoles = catchAsync(async (req, res, next) => {
})
})
exports.createRole = catchAsync(async (req, res, next) => {
const role = await roleService.createRole(req.body)
+11 -2
View File
@@ -1,10 +1,19 @@
const Role = require('./roleModel')
const AppError = require('../../globalErrorHandler/AppError')
module.exports.getAllRoles = async () => {
return Role.find({}).populate('permissions').sort({ createdAt: -1 })
module.exports.getAllRoles = async (query = {}) => {
const mongoQuery = {}
const search = (query.search || query.query || '').trim()
if (search) {
mongoQuery.$or = [
{ nativeName: { $regex: search, $options: 'i' } },
{ englishName: { $regex: search, $options: 'i' } },
]
}
return Role.find(mongoQuery).populate('permissions').sort({ createdAt: -1 })
}
module.exports.createRole = async (data) => {
const role = await Role.create(data)
return Role.findById(role._id).populate('permissions')
+79
View File
@@ -0,0 +1,79 @@
const catchAsync = require('../../globalErrorHandler/catchAsync')
const settingService = require('./settingService')
exports.getSearchSettings = catchAsync(async (req, res, next) => {
const settings = await settingService.getSearchSettings()
res.status(200).json({
status: 'success',
results: settings.length,
data: settings,
})
})
exports.getSearchSettingByPage = catchAsync(async (req, res, next) => {
const setting = await settingService.getSearchSettingByPage(req.params.pageKey)
res.status(200).json({
status: 'success',
data: setting,
})
})
exports.updateSearchSetting = catchAsync(async (req, res, next) => {
const { searchMethod, extraConfig } = req.body
const setting = await settingService.setSearchMethod(req.params.pageKey, searchMethod, extraConfig)
res.status(200).json({
status: 'success',
data: setting,
})
})
exports.bulkUpdateSearchSettings = catchAsync(async (req, res, next) => {
const { settings } = req.body
const results = await settingService.bulkUpdateSearchSettings(settings)
res.status(200).json({
status: 'success',
results: results.length,
data: results,
})
})
exports.getSeedStatus = catchAsync(async (req, res, next) => {
const status = await settingService.getSeedStatus()
res.status(200).json({
status: 'success',
data: status,
})
})
exports.seedDatabase = catchAsync(async (req, res, next) => {
const result = await settingService.seedDatabase(req.user?._id)
res.status(200).json({
status: 'success',
data: result,
})
})
exports.getAutoSaveSetting = catchAsync(async (req, res, next) => {
const result = await settingService.getAutoSaveSetting()
res.status(200).json({
status: 'success',
data: result,
})
})
exports.updateAutoSaveSetting = catchAsync(async (req, res, next) => {
const { enabled, intervalSeconds } = req.body
const result = await settingService.updateAutoSaveSetting({ enabled, intervalSeconds })
res.status(200).json({
status: 'success',
data: result,
})
})
+46
View File
@@ -0,0 +1,46 @@
const mongoose = require('mongoose')
const settingSchema = new mongoose.Schema(
{
key: {
type: String,
required: true,
unique: true,
trim: true,
},
category: {
type: String,
default: 'search',
},
pageKey: {
type: String,
required: true,
trim: true,
},
title: {
type: String,
required: true,
},
description: {
type: String,
},
searchMethod: {
type: String,
enum: ['typesense', 'mongo'],
default: 'mongo',
},
extraConfig: {
type: mongoose.Schema.Types.Mixed,
default: {},
},
},
{
timestamps: true,
}
)
settingSchema.index({ key: 1 }, { unique: true })
settingSchema.index({ pageKey: 1 })
const Setting = mongoose.model('Setting', settingSchema)
module.exports = Setting
+58
View File
@@ -0,0 +1,58 @@
const express = require('express')
const settingController = require('./settingController')
const perm = require('../../middlewares/permissionMiddleware')
const loginCheck = require('../../middlewares/loginCheck')
const router = new express.Router()
router.use(loginCheck.loginProtect)
router.get(
'/search-settings',
perm.requires('admindashboard', 'viewdashboard', 'getallpermissions'),
settingController.getSearchSettings
)
router.get(
'/search-settings/:pageKey',
perm.requires('admindashboard', 'viewdashboard', 'getallpermissions'),
settingController.getSearchSettingByPage
)
router.put(
'/search-settings/:pageKey',
perm.requires('admindashboard', 'viewdashboard', 'getallpermissions'),
settingController.updateSearchSetting
)
router.put(
'/search-settings',
perm.requires('admindashboard', 'viewdashboard', 'getallpermissions'),
settingController.bulkUpdateSearchSettings
)
router.get(
'/seed-status',
perm.requires('admindashboard', 'viewdashboard', 'getallpermissions'),
settingController.getSeedStatus
)
router.post(
'/seed-database',
perm.requires('admindashboard', 'viewdashboard', 'getallpermissions'),
settingController.seedDatabase
)
router.get(
'/autosave',
perm.requires('admindashboard', 'viewdashboard', 'getallpermissions'),
settingController.getAutoSaveSetting
)
router.put(
'/autosave',
perm.requires('admindashboard', 'viewdashboard', 'getallpermissions'),
settingController.updateAutoSaveSetting
)
module.exports = router
+289
View File
@@ -0,0 +1,289 @@
const Setting = require('./settingModel')
const AppError = require('../../globalErrorHandler/AppError')
const DEFAULT_SEARCH_PAGES = [
{
pageKey: 'properties',
title: 'املاک (Properties)',
description: 'جستجوی املاک، کد نوسازی، شناسه ملی جغرافیایی، مشخصات سند، مالک و پلاک ثبتی',
defaultMethod: 'typesense',
supportedMethods: ['typesense', 'mongo'],
endpointTypesense: '/api/properties/admin/search',
endpointMongo: '/api/properties/admin/search-mongo',
routePath: '/property/list-properties',
icon: 'pi pi-file',
},
{
pageKey: 'transactions',
title: 'تراکنش‌های املاک (Transactions)',
description: 'جستجوی تراکنش‌های واریز و برداشت، توضیحات، نام کاربر و ملک مرتبط',
defaultMethod: 'typesense',
supportedMethods: ['typesense', 'mongo'],
endpointTypesense: '/api/transactions/admin/search',
endpointMongo: '/api/transactions/admin/search-mongo',
routePath: '/transactions/list-transactions',
icon: 'pi pi-history',
},
{
pageKey: 'warehouseItems',
title: 'اقلام انبار (Warehouse Items)',
description: 'جستجوی اقلام کالا، نام فارسی، نام انگلیسی، کد کالا و دسته‌بندی',
defaultMethod: 'mongo',
supportedMethods: ['mongo', 'typesense'],
endpointTypesense: '/api/warehouse-items/admin/items',
endpointMongo: '/api/warehouse-items/admin/items',
routePath: '/warehouse-items/list',
icon: 'pi pi-box',
},
{
pageKey: 'warehouses',
title: 'مکان‌های انبار (Warehouses)',
description: 'جستجوی نام انبار، کد و آدرس انبارها',
defaultMethod: 'mongo',
supportedMethods: ['mongo', 'typesense'],
endpointTypesense: '/api/warehouses/admin/get-all',
endpointMongo: '/api/warehouses/admin/get-all',
routePath: '/warehouse-items/warehouses',
icon: 'pi pi-map',
},
{
pageKey: 'categories',
title: 'دسته‌بندی‌ها (Categories)',
description: 'جستجوی دسته‌بندی‌های املاک بر اساس نام فارسی و انگلیسی',
defaultMethod: 'mongo',
supportedMethods: ['mongo', 'typesense'],
endpointTypesense: '/api/categories/admin/get-all',
endpointMongo: '/api/categories/admin/get-all',
routePath: '/categories/list-categories',
icon: 'pi pi-th-large',
},
{
pageKey: 'users',
title: 'کاربران (Users)',
description: 'جستجوی کاربران سیستم بر اساس نام، ایمیل و نقش کاربری',
defaultMethod: 'mongo',
supportedMethods: ['mongo', 'typesense'],
endpointTypesense: '/api/users/admin/get-all',
endpointMongo: '/api/users/admin/get-all',
routePath: '/users/list-users',
icon: 'pi pi-users',
},
{
pageKey: 'roles',
title: 'نقش‌ها و دسترسی‌ها (Roles)',
description: 'جستجوی نقش‌ها بر اساس عنوان فارسی و انگلیسی',
defaultMethod: 'mongo',
supportedMethods: ['mongo', 'typesense'],
endpointTypesense: '/api/roles/admin/get-all',
endpointMongo: '/api/roles/admin/get-all',
routePath: '/roles/list-roles',
icon: 'pi pi-shield',
},
]
const getSearchSettings = async () => {
const dbSettings = await Setting.find({ category: 'search' }).lean()
const dbSettingsMap = new Map()
dbSettings.forEach((s) => {
dbSettingsMap.set(s.pageKey, s)
})
return DEFAULT_SEARCH_PAGES.map((pageDef) => {
const dbRecord = dbSettingsMap.get(pageDef.pageKey)
if (dbRecord) {
return {
...pageDef,
_id: dbRecord._id,
searchMethod: dbRecord.searchMethod || pageDef.defaultMethod,
isPersisted: true,
updatedAt: dbRecord.updatedAt,
createdAt: dbRecord.createdAt,
}
}
return {
...pageDef,
searchMethod: pageDef.defaultMethod,
isPersisted: false,
}
})
}
const getSearchSettingByPage = async (pageKey) => {
const pageDef = DEFAULT_SEARCH_PAGES.find((p) => p.pageKey === pageKey)
if (!pageDef) {
throw new AppError('PageNotFound', 404, { message: `Page key ${pageKey} not found in search configurations` })
}
const dbRecord = await Setting.findOne({ pageKey, category: 'search' }).lean()
if (dbRecord) {
return {
...pageDef,
_id: dbRecord._id,
searchMethod: dbRecord.searchMethod || pageDef.defaultMethod,
isPersisted: true,
updatedAt: dbRecord.updatedAt,
createdAt: dbRecord.createdAt,
}
}
return {
...pageDef,
searchMethod: pageDef.defaultMethod,
isPersisted: false,
}
}
const setSearchMethod = async (pageKey, searchMethod, extraConfig = {}) => {
if (!['typesense', 'mongo'].includes(searchMethod)) {
throw new AppError('InvalidSearchMethod', 400, { message: 'Search method must be either "typesense" or "mongo"' })
}
const pageDef = DEFAULT_SEARCH_PAGES.find((p) => p.pageKey === pageKey) || {
pageKey,
title: pageKey,
description: `Settings for ${pageKey}`,
defaultMethod: 'mongo',
}
const setting = await Setting.findOneAndUpdate(
{ pageKey, category: 'search' },
{
key: `search_${pageKey}`,
category: 'search',
pageKey,
title: pageDef.title,
description: pageDef.description,
searchMethod,
extraConfig,
},
{ upsert: true, new: true, setDefaultsOnInsert: true }
)
return {
...pageDef,
_id: setting._id,
searchMethod: setting.searchMethod,
isPersisted: true,
updatedAt: setting.updatedAt,
createdAt: setting.createdAt,
}
}
const seederRunner = require('../../seeder/seederRunner')
const bulkUpdateSearchSettings = async (settingsArray) => {
if (!Array.isArray(settingsArray)) {
throw new AppError('InvalidDataFormat', 400, { message: 'Expected an array of settings' })
}
const results = []
for (const item of settingsArray) {
if (item.pageKey && item.searchMethod) {
const updated = await setSearchMethod(item.pageKey, item.searchMethod, item.extraConfig || {})
results.push(updated)
}
}
return results
}
const getSeedStatus = async () => {
const record = await Setting.findOne({ key: 'system_database_seeded' }).lean()
if (record && record.extraConfig && record.extraConfig.isSeeded) {
return {
isSeeded: true,
seededAt: record.extraConfig.seededAt || record.updatedAt,
seededBy: record.extraConfig.seededBy || null,
}
}
return {
isSeeded: false,
seededAt: null,
seededBy: null,
}
}
const seedDatabase = async (userId = null) => {
const currentStatus = await getSeedStatus()
if (currentStatus.isSeeded) {
throw new AppError('DatabaseAlreadySeeded', 400, {
message: 'پایگاه داده قبلاً مقداردهی شده است و امکان مقداردهی مجدد وجود ندارد.',
})
}
await seederRunner.run()
const record = await Setting.findOneAndUpdate(
{ key: 'system_database_seeded' },
{
key: 'system_database_seeded',
category: 'system',
pageKey: 'system_seeder',
title: 'مقداردهی اولیه پایگاه داده',
description: 'وضعیت اجرای مقداردهی اولیه داده‌های پایه سیستم',
extraConfig: {
isSeeded: true,
seededAt: new Date(),
seededBy: userId,
},
},
{ upsert: true, new: true, setDefaultsOnInsert: true }
)
return {
isSeeded: true,
seededAt: record.extraConfig.seededAt,
message: 'پایگاه داده با موفقیت مقداردهی اولیه شد.',
}
}
const getAutoSaveSetting = async () => {
const record = await Setting.findOne({ key: 'general_autosave' }).lean()
if (record && record.extraConfig) {
return {
enabled: record.extraConfig.enabled !== undefined ? record.extraConfig.enabled : true,
intervalSeconds: Number(record.extraConfig.intervalSeconds) || 60,
updatedAt: record.updatedAt,
}
}
return {
enabled: true,
intervalSeconds: 60,
}
}
const updateAutoSaveSetting = async ({ enabled = true, intervalSeconds = 60 }) => {
const validInterval = Math.max(10, Number(intervalSeconds) || 60)
const setting = await Setting.findOneAndUpdate(
{ key: 'general_autosave' },
{
key: 'general_autosave',
category: 'general',
pageKey: 'general',
title: 'تنظیمات ذخیره خودکار (Auto-Save Settings)',
description: 'پیکربندی فعال‌سازی و فاصله زمانی ذخیره خودکار فرم‌ها',
extraConfig: {
enabled: Boolean(enabled),
intervalSeconds: validInterval,
},
},
{ upsert: true, new: true, setDefaultsOnInsert: true }
)
return {
enabled: setting.extraConfig.enabled,
intervalSeconds: setting.extraConfig.intervalSeconds,
updatedAt: setting.updatedAt,
}
}
module.exports = {
DEFAULT_SEARCH_PAGES,
getSearchSettings,
getSearchSettingByPage,
setSearchMethod,
bulkUpdateSearchSettings,
getSeedStatus,
seedDatabase,
getAutoSaveSetting,
updateAutoSaveSetting,
}
@@ -2,7 +2,7 @@ const catchAsync = require('../../globalErrorHandler/catchAsync')
const transactionService = require('./transactionService')
exports.searchTransactions = catchAsync(async (req, res, next) => {
const { facets, transactions, totalCount, pagesCount } = await transactionService.searchTransactions(req.query)
const { facets, transactions, totalCount, pagesCount, searchEngine } = await transactionService.searchTransactions(req.query)
res.status(200).json({
status: 'success',
@@ -10,9 +10,24 @@ exports.searchTransactions = catchAsync(async (req, res, next) => {
transactions,
totalCount,
pagesCount,
searchEngine,
})
})
exports.searchTransactionsMongo = catchAsync(async (req, res, next) => {
const { facets, transactions, totalCount, pagesCount, searchEngine } = await transactionService.searchTransactionsMongo(req.query)
res.status(200).json({
status: 'success',
facets,
transactions,
totalCount,
pagesCount,
searchEngine: searchEngine || 'mongo',
})
})
exports.indexAllTransactions = catchAsync(async (req, res, next) => {
const { indexedCount } = await transactionService.indexAllTransactions()
@@ -31,6 +31,13 @@ router.get(
transactionController.searchTransactions
)
router.get(
'/admin/search-mongo',
// perm.requires('getallbelongings'),
transactionController.searchTransactionsMongo
)
router.get(
'/admin/get-stock-by-belonging/:belongingId',
// perm.requires('getallbelongings'),
+108 -5
View File
@@ -165,15 +165,116 @@ const ensureCollectionExists = async (forceRecreate = false) => {
}
}
const settingService = require('../setting/settingService')
const searchTransactionsMongo = async (inputQuery = {}) => {
const page = parseInt(inputQuery.page) || 1
const per_page = parseInt(inputQuery.per_page) || parseInt(inputQuery.limit) || 20
const mongoQuery = {}
const q = (inputQuery.query || inputQuery.search || inputQuery.q || '').trim()
if (q && q !== '*') {
const searchRegex = { $regex: q, $options: 'i' }
const orConditions = [
{ description: searchRegex },
{ userName: searchRegex },
{ type: searchRegex },
{ status: searchRegex },
{ source: searchRegex },
]
if (mongoose.Types.ObjectId.isValid(q)) {
orConditions.push({ _id: q })
orConditions.push({ propertyId: q })
orConditions.push({ userId: q })
}
mongoQuery.$or = orConditions
}
if (inputQuery.type) mongoQuery.type = inputQuery.type
if (inputQuery.status) mongoQuery.status = inputQuery.status
if (inputQuery.source) mongoQuery.source = inputQuery.source
if (inputQuery.propertyId && mongoose.Types.ObjectId.isValid(inputQuery.propertyId)) {
mongoQuery.propertyId = inputQuery.propertyId
}
if (inputQuery.userId && mongoose.Types.ObjectId.isValid(inputQuery.userId)) {
mongoQuery.userId = inputQuery.userId
}
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 Transaction.countDocuments(mongoQuery)
const transactions = await Transaction.find(mongoQuery)
.populate('propertyId')
.populate('userId')
.sort(sortObj)
.skip((page - 1) * per_page)
.limit(per_page)
const items = transactions.map((t) => {
return {
_id: t._id.toString(),
id: t._id.toString(),
type: t.type || '',
status: t.status || '',
source: t.source || '',
value: t.value || 0,
description: t.description || '',
createdAt: Math.floor(new Date(t.createdAt || t.date || Date.now()).getTime() / 1000),
date: t.date,
belongingName: t.propertyId?.name || '',
belongingId: t.propertyId?._id ? t.propertyId._id.toString() : '',
propertyId: t.propertyId,
userName: t.userName || t.userId?.name || '',
userId: t.userId,
}
})
const pagesCount = Math.ceil(totalCount / per_page) || 1
return {
facets: [],
transactions: items,
totalCount,
pagesCount,
searchEngine: 'mongo',
}
}
const searchTransactions = async (inputQuery) => {
await ensureCollectionExists(false)
inputQuery = updateInputQuery(inputQuery, {})
try {
const setting = await settingService.getSearchSettingByPage('transactions')
if (setting && setting.searchMethod === 'mongo') {
return searchTransactionsMongo(inputQuery)
}
} catch (e) {
console.warn('Failed to fetch search setting for transactions, attempting typesense:', e.message)
}
const tsFeatures = new TsApiFeatures(client, 'transactions', inputQuery).filter().facet().sort().limitFields().paginate().search()
try {
await ensureCollectionExists(false)
const updatedQuery = updateInputQuery({ ...inputQuery }, {})
let { facets, items, totalCount, pagesCount } = await tsFeatures.execute()
const tsFeatures = new TsApiFeatures(client, 'transactions', updatedQuery).filter().facet().sort().limitFields().paginate().search()
return { facets, transactions: items, totalCount, pagesCount }
let { facets, items, totalCount, pagesCount } = await tsFeatures.execute()
return { facets, transactions: items, totalCount, pagesCount, searchEngine: 'typesense' }
} catch (err) {
console.warn('Typesense search failed for transactions, falling back to MongoDB:', err.message)
return searchTransactionsMongo(inputQuery)
}
}
const updateInputQuery = (inputQuery, extraQueries) => {
@@ -257,4 +358,6 @@ module.exports = {
getTransactionByBelongingId,
indexAllTransactions,
searchTransactions,
searchTransactionsMongo,
}
+24 -7
View File
@@ -26,6 +26,11 @@ exports.loginUser = catchAsync(async (req, res, next) => {
maxAge: maxAge * 1000,
path: '/',
})
res.cookie('bm-token', token, {
httpOnly: false,
maxAge: maxAge * 1000,
path: '/',
})
res.status(200).json({
status: 'success',
@@ -34,20 +39,31 @@ exports.loginUser = catchAsync(async (req, res, next) => {
})
exports.logoutUser = catchAsync(async (req, res, next) => {
res.cookie('belonging_token', '', {
httpOnly: true,
maxAge: 0,
path: '/',
})
const isProduction = process.env.NODE_ENV === 'production'
// Clear default host cookies
res.clearCookie('belonging_token', { path: '/' })
res.clearCookie('bm-token', { path: '/' })
res.clearCookie('jwt', { path: '/' })
// Clear domain cookies if in production or mbmanager domain
if (isProduction || req.hostname.includes('mbmanager.ir')) {
res.clearCookie('belonging_token', { domain: '.mbmanager.ir', path: '/' })
res.clearCookie('bm-token', { domain: '.mbmanager.ir', path: '/' })
res.clearCookie('jwt', { domain: '.mbmanager.ir', path: '/' })
}
res.cookie('belonging_token', '', { maxAge: 0, expires: new Date(0), path: '/' })
res.cookie('bm-token', '', { maxAge: 0, expires: new Date(0), path: '/' })
res.status(200).json({
status: 'success',
message: 'کاربر خارج شد',
message: 'کاربر با موفقیت خارج شد',
})
})
exports.getAllUsers = catchAsync(async (req, res, next) => {
const users = await userService.getAllUsers()
const users = await userService.getAllUsers(req.query)
res.status(200).json({
status: 'success',
@@ -56,6 +72,7 @@ exports.getAllUsers = catchAsync(async (req, res, next) => {
})
})
exports.userGetSelf = catchAsync(async (req, res, next) => {
const user = req.user
if (!user) {
+11 -2
View File
@@ -159,10 +159,19 @@ const loginUser = async (email, password) => {
return { token: signToken(user._id), user }
}
const getAllUsers = async () => {
return User.find({}).populate('role').sort({ createdAt: -1 })
const getAllUsers = async (query = {}) => {
const mongoQuery = {}
const search = (query.search || query.query || '').trim()
if (search) {
mongoQuery.$or = [
{ name: { $regex: search, $options: 'i' } },
{ email: { $regex: search, $options: 'i' } },
]
}
return User.find(mongoQuery).populate('role').sort({ createdAt: -1 })
}
const getUser = async (userId) => {
const user = await User.findById(userId).populate('role')
+176
View File
@@ -0,0 +1,176 @@
const Warehouse = require('./warehouseModel')
const WarehouseItem = require('../warehouseItem/warehouseItemModel')
const WarehouseItemTransaction = require('../warehouseItem/warehouseItemTransactionModel')
const WarehouseItemStock = require('../warehouseItem/warehouseItemStockModel')
const User = require('../user/userModel')
const chalk = require('chalk')
const runSeeder = async () => {
// 1. Find or create admin user for actor
const adminUser = await User.findOne({ email: 'superadmin@mbmanager.ir' }) || await User.findOne()
// 2. Warehouses
const sampleWarehouses = [
{
name: 'انبار مرکزی تهران',
code: 'WH-TEH-01',
location: 'تهران، جاده مخصوص کرج، کیلومتر ۱۱',
description: 'انبار اصلی مصالح ساختمانی و تجهیزات مکانیکی',
isActive: true,
},
{
name: 'انبار پروژه نیاوران',
code: 'WH-NYA-02',
location: 'تهران، نیاوران، خیابان باهنر',
description: 'انبار پای کار پروژه برج یاس',
isActive: true,
},
{
name: 'انبار مصالح اصفهان',
code: 'WH-ISF-01',
location: 'اصفهان، شهرک صنعتی جی',
description: 'انبار پشتیبانی پروژه‌های مرکزی',
isActive: true,
},
]
const createdWarehouses = []
for (const wh of sampleWarehouses) {
let doc = await Warehouse.findOne({ name: wh.name })
if (!doc) {
doc = await Warehouse.create(wh)
}
createdWarehouses.push(doc)
}
// 3. Warehouse Items
const sampleItems = [
{
nameFa: 'سیمان تیپ ۲ (کیسه ۵۰ کیلویی)',
nameEn: 'Cement Type 2 (50kg)',
code: 'CEM-T2-50',
unitFa: 'کیسه',
unitEn: 'Bag',
category: 'مصالح پایه',
description: 'سیمان پرتلند تیپ ۲ استاندارد',
},
{
nameFa: 'میلگرد آجدار سایز ۱۶ (A3)',
nameEn: 'Rebar A3 Size 16',
code: 'REB-16-A3',
unitFa: 'شاخه',
unitEn: 'Piece',
category: 'آهن آلات',
description: 'میلگرد شاخه ۱۲ متری فولاد مبارکه',
},
{
nameFa: 'سرامیک پرسلان ۶۰*۱۲۰',
nameEn: 'Porcelain Tile 60x120',
code: 'TIL-60120-P',
unitFa: 'مترمربع',
unitEn: 'Sqm',
category: 'نازک‌کاری',
description: 'سرامیک درجه یک کالیبره مات',
},
{
nameFa: 'لوله پلی‌اتیلن سایز ۶۳',
nameEn: 'PE Pipe Size 63',
code: 'PIP-PE-63',
unitFa: 'متر',
unitEn: 'Meter',
category: 'تأسیسات مکانیکی',
description: 'لوله آبرسانی ۱۰ بار استاندارد',
},
{
nameFa: 'رنگ اکریلیک پایه آب (حلب ۲۰ لیتری)',
nameEn: 'Acrylic Paint 20L',
code: 'PNT-ACR-20L',
unitFa: 'حلب',
unitEn: 'Can',
category: 'رنگ و نقاشی',
description: 'رنگ مات قابل شستشو درجه یک',
},
]
const createdItems = []
for (const it of sampleItems) {
let doc = await WarehouseItem.findOne({ code: it.code })
if (!doc) {
doc = await WarehouseItem.create(it)
}
createdItems.push(doc)
}
// 4. Sample Transactions
const txCount = await WarehouseItemTransaction.countDocuments()
if (txCount === 0 && adminUser && createdWarehouses.length > 0 && createdItems.length > 0) {
const sampleTransactions = [
{
item: createdItems[0]._id,
warehouse: createdWarehouses[0]._id,
type: 'IN',
quantity: 500,
unit: createdItems[0].unitFa,
reason: 'خرید مستقیم از کارخانه سیمان تهران',
actor: adminUser._id,
actorName: adminUser.name || 'مدیر سیستم',
note: 'بارگیری از کارخانه با فاکتور شماره ۱۲۸۹',
},
{
item: createdItems[1]._id,
warehouse: createdWarehouses[0]._id,
type: 'IN',
quantity: 120,
unit: createdItems[1].unitFa,
reason: 'تأمین آهن‌آلات اسکلت سازه',
actor: adminUser._id,
actorName: adminUser.name || 'مدیر سیستم',
},
{
item: createdItems[0]._id,
warehouse: createdWarehouses[1]._id,
type: 'OUT',
quantity: 80,
unit: createdItems[0].unitFa,
reason: 'مصرف در بتن‌ریزی سقف طبقه پنجم',
actor: adminUser._id,
actorName: adminUser.name || 'مدیر سیستم',
},
{
item: createdItems[2]._id,
warehouse: createdWarehouses[0]._id,
type: 'IN',
quantity: 350,
unit: createdItems[2].unitFa,
reason: 'خرید مصالح نازک‌کاری',
actor: adminUser._id,
actorName: adminUser.name || 'مدیر سیستم',
},
{
item: createdItems[3]._id,
warehouse: createdWarehouses[1]._id,
type: 'IN',
quantity: 200,
unit: createdItems[3].unitFa,
reason: 'تجهیز تأسیسات لوله‌کشی آب',
actor: adminUser._id,
actorName: adminUser.name || 'مدیر سیستم',
},
]
for (const tx of sampleTransactions) {
await WarehouseItemTransaction.create(tx)
// Update or create stock
const delta = tx.type === 'IN' ? tx.quantity : -tx.quantity
await WarehouseItemStock.findOneAndUpdate(
{ item: tx.item, warehouse: tx.warehouse },
{ $inc: { quantity: delta } },
{ upsert: true, new: true }
)
}
console.log(chalk.green.bold(`WAREHOUSE: Created ${sampleTransactions.length} sample inventory transactions`))
}
}
module.exports = { runSeeder }
+2
View File
@@ -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) => {
+10
View File
@@ -0,0 +1,10 @@
const { startPropertyIndexCron } = require('./propertyIndexCron')
const initCronJobs = () => {
startPropertyIndexCron()
console.log('Cron jobs initialized successfully')
}
module.exports = {
initCronJobs,
}
+90
View File
@@ -0,0 +1,90 @@
const cron = require('node-cron')
const Property = require('../components/property/propertyModel')
const propertyService = require('../components/property/propertyService')
const { client } = require('../setup/typesense')
let lastIndexedAt = new Date(0)
let lastIndexedCount = null
let isIndexing = false
const checkAndIndexProperties = async () => {
if (isIndexing) {
console.log('[Property Index Cron] Indexing already in progress. Skipping this run.')
return
}
isIndexing = true
try {
const mongoCount = await Property.countDocuments({ isActive: true })
// Check Typesense collection document count
let typesenseCount = 0
let collectionExists = false
try {
const collection = await client.collections('properties').retrieve()
typesenseCount = collection.num_documents || 0
collectionExists = true
} catch (err) {
collectionExists = false
}
// Count new or updated properties in MongoDB since last index
const query = {
isActive: true,
$or: [
{ updatedAt: { $gt: lastIndexedAt } },
{ createdAt: { $gt: lastIndexedAt } }
]
}
const changedCount = await Property.countDocuments(query)
const shouldIndex =
!collectionExists ||
(typesenseCount === 0 && mongoCount > 0) ||
mongoCount !== typesenseCount ||
(lastIndexedCount !== null && mongoCount !== lastIndexedCount) ||
changedCount > 0
if (shouldIndex) {
console.log(
`[Property Index Cron] Changes detected (MongoDB: ${mongoCount}, Typesense: ${typesenseCount}, New/Updated: ${changedCount}). Starting indexing...`
)
const result = await propertyService.indexAllProperties()
lastIndexedAt = new Date()
lastIndexedCount = mongoCount
console.log(`[Property Index Cron] Successfully indexed ${result.totalCount} properties.`)
} else {
console.log(
`[Property Index Cron] No new or updated properties found since ${lastIndexedAt.toISOString()} (MongoDB: ${mongoCount}, Typesense: ${typesenseCount}). Skipping.`
)
}
} catch (err) {
console.error('[Property Index Cron] Error during property indexing check:', err)
} finally {
isIndexing = false
}
}
const startPropertyIndexCron = () => {
// Schedule every 5 minutes
const task = cron.schedule('*/5 * * * *', async () => {
console.log('[Property Index Cron] Running scheduled 5-minute property index check...')
await checkAndIndexProperties()
})
console.log('[Property Index Cron] Scheduled to run every 5 minutes (*/5 * * * *)')
// Initial check after startup
setTimeout(() => {
checkAndIndexProperties().catch((err) => {
console.error('[Property Index Cron] Initial index check failed:', err)
})
}, 10000)
return task
}
module.exports = {
startPropertyIndexCron,
checkAndIndexProperties,
}
+8 -3
View File
@@ -8,10 +8,15 @@ exports.loginProtect = async (req, res, next) => {
let token
token = req.cookies['bm-token']
if (req.headers.authorization && req.headers.authorization.startsWith('Bearer')) {
token = req.headers.authorization.split(' ')[1]
const headerToken = req.headers.authorization.split(' ')[1]
if (headerToken && headerToken !== 'undefined' && headerToken !== 'null' && headerToken.trim() !== '') {
token = headerToken.trim()
}
}
if (!token && req.cookies) {
token = req.cookies['bm-token'] || req.cookies['belonging_token'] || req.cookies['jwt']
}
if (!token) {
+10
View File
@@ -23,6 +23,7 @@
"mongoose": "^8.9.0",
"mongoose-sequence": "^6.0.1",
"multer": "^1.4.5-lts.1",
"node-cron": "^4.6.0",
"typesense": "^1.8.2",
"validator": "^13.12.0"
},
@@ -1572,6 +1573,15 @@
"node": ">= 0.6"
}
},
"node_modules/node-cron": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.6.0.tgz",
"integrity": "sha512-Si/bzYiKRHOB8/a99T2+SDGN582ONDMSTlJr5oCkT6GtnqPjZ2s10eoQRYkW9ZHwjVxONL+W8Fb+qR0AHMQsdg==",
"license": "ISC",
"engines": {
"node": ">=20"
}
},
"node_modules/nodemon": {
"version": "3.1.9",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.9.tgz",
+1
View File
@@ -27,6 +27,7 @@
"mongoose": "^8.9.0",
"mongoose-sequence": "^6.0.1",
"multer": "^1.4.5-lts.1",
"node-cron": "^4.6.0",
"typesense": "^1.8.2",
"validator": "^13.12.0"
},
+16 -2
View File
@@ -3,6 +3,8 @@ const categorySeeder = require('../components/category/categorySeeder')
const roleSeeder = require('../components/role/roleSeeder')
const featureSeeder = require('../components/feature/featureSeeder')
const propertyStatusSeeder = require('../components/propertyStatus/propertyStatusSeeder')
const propertySeeder = require('../components/property/propertySeeder')
const warehouseSeeder = require('../components/warehouse/warehouseSeeder')
const propertyService = require('../components/property/propertyService')
const transactionService = require('../components/transaction/transactionService')
@@ -12,6 +14,18 @@ module.exports.run = async () => {
await roleSeeder.runSeeder()
await featureSeeder.runSeeder()
await propertyStatusSeeder.runSeeder()
await propertyService.indexAllProperties()
await transactionService.indexAllTransactions()
await propertySeeder.runSeeder()
await warehouseSeeder.runSeeder()
try {
await propertyService.indexAllProperties()
} catch (err) {
console.warn('Typesense property indexing warning during seed:', err.message)
}
try {
await transactionService.indexAllTransactions()
} catch (err) {
console.warn('Typesense transaction indexing warning during seed:', err.message)
}
}
+10
View File
@@ -23,6 +23,16 @@ module.exports = (app) => {
const { syncPermissions } = require('../components/permission/permissionService')
await syncPermissions()
// Ensure default MinIO buckets exist (temp, properties, documents)
const { ensureBucketsExist } = require('../utils/upload/bucket')
await ensureBucketsExist(['temp', 'properties', 'documents']).catch((err) => {
console.error('Failed to ensure default MinIO buckets:', err)
})
// Initialize cron jobs
const { initCronJobs } = require('../jobs/index')
initCronJobs()
app.listen(port, function () {
console.log('Server Started On Port ' + port)
})
+5
View File
@@ -10,9 +10,12 @@ const fileRoutes = require('../components/file/fileRoutes')
const featureRoutes = require('../components/feature/featureRoutes')
const warehouseItemRoutes = require('../components/warehouseItem/warehouseItemRoutes')
const warehouseRoutes = require('../components/warehouse/warehouseRoutes')
const settingRoutes = require('../components/setting/settingRoutes')
const dashboardRoutes = require('../components/dashboard/dashboardRoutes')
const routerEndpoints = new express.Router()
routerEndpoints.use('/dashboard', dashboardRoutes)
routerEndpoints.use('/users', userRoutes)
routerEndpoints.use('/permissions', permissionRoutes)
routerEndpoints.use('/properties', propertyRoutes)
@@ -27,5 +30,7 @@ routerEndpoints.use('/files', fileRoutes)
routerEndpoints.use('/features', featureRoutes)
routerEndpoints.use('/warehouse-items', warehouseItemRoutes)
routerEndpoints.use('/warehouses', warehouseRoutes)
routerEndpoints.use('/settings', settingRoutes)
module.exports = routerEndpoints
+174
View File
@@ -0,0 +1,174 @@
/**
* Jalali (Solar Hijri / Shamsi) calendar conversion utilities (Backend)
*/
const persianMonths = [
{ value: 1, id: 1, label: 'فروردین', name: 'فروردین' },
{ value: 2, id: 2, label: 'اردیبهشت', name: 'اردیبهشت' },
{ value: 3, id: 3, label: 'خرداد', name: 'خرداد' },
{ value: 4, id: 4, label: 'تیر', name: 'تیر' },
{ value: 5, id: 5, label: 'مرداد', name: 'مرداد' },
{ value: 6, id: 6, label: 'شهریور', name: 'شهریور' },
{ value: 7, id: 7, label: 'مهر', name: 'مهر' },
{ value: 8, id: 8, label: 'آبان', name: 'آبان' },
{ value: 9, id: 9, label: 'آذر', name: 'آذر' },
{ value: 10, id: 10, label: 'دی', name: 'دی' },
{ value: 11, id: 11, label: 'بهمن', name: 'بهمن' },
{ value: 12, id: 12, label: 'اسفند', name: 'اسفند' }
]
function toEnglishDigits(str) {
if (str === null || str === undefined) return ''
return String(str)
.replace(/[۰-۹]/g, (d) => '۰۱۲۳۴۵۶۷۸۹'.indexOf(d))
.replace(/[٠-٩]/g, (d) => '٠١٢٣٤٥٦٧٨٩'.indexOf(d))
}
function jalaliToGregorian(jy, jm, jd) {
jy = Number(jy)
jm = Number(jm)
jd = Number(jd)
let gy = jy > 979 ? 1600 : 621
jy = jy > 979 ? jy - 979 : jy
let days =
365 * jy +
Math.floor(jy / 33) * 8 +
Math.floor(((jy % 33) + 3) / 4) +
78 +
jd +
(jm < 7 ? (jm - 1) * 31 : (jm - 7) * 30 + 186)
gy += 400 * Math.floor(days / 146097)
days %= 146097
if (days > 36524) {
gy += 100 * Math.floor(--days / 36524)
days %= 36524
if (days >= 365) days++
}
gy += 4 * Math.floor(days / 1461)
days %= 1461
if (days > 365) {
gy += Math.floor((days - 1) / 365)
days = (days - 1) % 365
}
let gd = days + 1
const isLeap = (gy % 4 === 0 && gy % 100 !== 0) || gy % 400 === 0
const sal_a = [0, 31, isLeap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
let gm
for (gm = 0; gm < 13; gm++) {
const v = sal_a[gm]
if (gd <= v) break
gd -= v
}
return { gy, gm, gd }
}
function gregorianToJalali(gy, gm, gd) {
gy = Number(gy)
gm = Number(gm)
gd = Number(gd)
const g_d_m = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334]
let jy = gy > 1600 ? 979 : 0
gy = gy > 1600 ? gy - 1600 : gy - 621
const gy2 = gm > 2 ? gy + 1 : gy
let days =
365 * gy +
Math.floor((gy2 + 3) / 4) -
Math.floor((gy2 + 99) / 100) +
Math.floor((gy2 + 399) / 400) -
80 +
gd +
g_d_m[gm - 1]
jy += 33 * Math.floor(days / 12053)
days %= 12053
jy += 4 * Math.floor(days / 1461)
days %= 1461
if (days > 365) {
jy += Math.floor((days - 1) / 365)
days = (days - 1) % 365
}
const jm = days < 186 ? 1 + Math.floor(days / 31) : 7 + Math.floor((days - 186) / 30)
const jd = 1 + (days < 186 ? days % 31 : (days - 186) % 30)
return { jy, jm, jd }
}
function jalaliToDate(jy, jm, jd) {
const y = parseInt(toEnglishDigits(jy), 10)
const m = parseInt(toEnglishDigits(jm), 10)
const d = parseInt(toEnglishDigits(jd), 10)
if (isNaN(y) || isNaN(m) || isNaN(d) || y <= 0 || m < 1 || m > 12 || d < 1 || d > 31) {
return null
}
const { gy, gm, gd } = jalaliToGregorian(y, m, d)
return new Date(Date.UTC(gy, gm - 1, gd, 12, 0, 0))
}
function parseDateToJalali(dateVal) {
if (!dateVal) return null
if (dateVal instanceof Date || (typeof dateVal === 'string' && (dateVal.includes('T') || dateVal.includes('-')))) {
const d = new Date(dateVal)
if (!isNaN(d.getTime())) {
return gregorianToJalali(d.getUTCFullYear(), d.getUTCMonth() + 1, d.getUTCDate())
}
}
if (typeof dateVal === 'string' && dateVal.includes('/')) {
const clean = toEnglishDigits(dateVal.trim())
const parts = clean.split('/')
if (parts.length === 3) {
const jy = parseInt(parts[0], 10)
const jm = parseInt(parts[1], 10)
const jd = parseInt(parts[2], 10)
if (!isNaN(jy) && !isNaN(jm) && !isNaN(jd)) {
return { jy, jm, jd }
}
}
}
return null
}
function normalizeToDate(input) {
if (!input) return null
if (input instanceof Date && !isNaN(input.getTime())) return input
if (typeof input === 'string') {
const trimmed = input.trim()
if (trimmed.includes('/')) {
const parts = toEnglishDigits(trimmed).split('/')
if (parts.length === 3) {
return jalaliToDate(parts[0], parts[1], parts[2])
}
}
const d = new Date(trimmed)
if (!isNaN(d.getTime())) return d
}
return null
}
module.exports = {
persianMonths,
toEnglishDigits,
jalaliToGregorian,
gregorianToJalali,
jalaliToDate,
parseDateToJalali,
normalizeToDate,
}
+28 -5
View File
@@ -1,6 +1,20 @@
const minioClient = require('./minio')
const fs = require('fs')
// Ensure buckets exist in MinIO
const ensureBucketsExist = async (bucketNames = ['temp', 'properties', 'documents']) => {
for (const bucketName of bucketNames) {
try {
const exists = await minioClient.bucketExists(bucketName).catch(() => false)
if (!exists) {
await minioClient.makeBucket(bucketName, process.env.AWS_REGION || 'us-east-1')
console.log(`MinIO bucket "${bucketName}" created successfully.`)
}
} catch (err) {
console.error(`Error ensuring MinIO bucket "${bucketName}" exists:`, err.message)
}
}
}
// Upload File to s3 bucket
exports.uploadObject = async (file, bucketName) => {
@@ -14,11 +28,12 @@ exports.uploadObject = async (file, bucketName) => {
const uploadedFile = await minioClient.putObject(bucketName, `${file.filename}-${file.originalName}`, fileStream)
const protocol = process.env.AWS_USE_SSL === 'true' ? 'https://' : 'http://'
const hostPort = process.env.AWS_PORT ? `${process.env.AWS_ENDPOINT}:${process.env.AWS_PORT}` : process.env.AWS_ENDPOINT
uploadedFile.fileURL = protocol + hostPort + '/' + bucketName + '/' + file.filename + '-' + file.originalName
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}`
uploadedFile.fileURL = fullHost.replace(/\/$/, '') + '/' + bucketName + '/' + file.filename + '-' + file.originalName
uploadedFile.fileName = file.filename + '-' + file.originalName
uploadedFile.fileEndpoint = process.env.AWS_ENDPOINT
return uploadedFile
@@ -52,10 +67,18 @@ exports.deleteObject = (fileName, bucketName) => {
return minioClient.removeObject(bucketName, fileName)
}
exports.copyObject = (targetBucket, fileName, sourceBucketAndFile) => {
return minioClient.copyObject(targetBucket, fileName, sourceBucketAndFile)
exports.copyObject = async (targetBucket, fileName, sourceBucketAndFile) => {
const bucketExists = await minioClient.bucketExists(targetBucket).catch(() => false)
if (!bucketExists) {
await minioClient.makeBucket(targetBucket, process.env.AWS_REGION || 'us-east-1')
}
const conds = minioClient.CopyConditions ? new minioClient.CopyConditions() : null
return minioClient.copyObject(targetBucket, fileName, sourceBucketAndFile, conds)
}
exports.removeObject = (bucketName, fileName) => {
return minioClient.removeObject(bucketName, fileName)
}
exports.ensureBucketsExist = ensureBucketsExist
exports.minioClient = minioClient