diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfe0770 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Auto detect text files and perform LF normalization +* text=auto diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c6bba59 --- /dev/null +++ b/.gitignore @@ -0,0 +1,130 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp +.cache + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..3b66410 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "git.ignoreLimitWarning": true +} \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0cd13d7 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Adib Sadman + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b231cc0 --- /dev/null +++ b/README.md @@ -0,0 +1,2 @@ +# rent_house_bd + House Rental Platrom for Bangladesh diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..4cbcfb4 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,26 @@ +# MongoDB Configuration +MONGODB_URI=mongodb://localhost:27017/house_rental + +# JWT Configuration +JWT_SECRET=your_jwt_secret_here +JWT_EXPIRES_IN=7d + +# Server Configuration +PORT=5000 +NODE_ENV=development + +# Google Cloud / Dialogflow Configuration +DIALOGFLOW_PROJECT_ID=your-project-id +GOOGLE_APPLICATION_CREDENTIALS=path/to/your/credentials.json + +# Email Configuration (if needed) +SMTP_HOST=smtp.example.com +SMTP_PORT=587 +SMTP_USER=your-email@example.com +SMTP_PASS=your-email-password + +# Frontend URL (for CORS) +FRONTEND_URL=http://localhost:3000 + +# Redis Configuration (if needed) +REDIS_URL=redis://localhost:6379 diff --git a/backend/config/db.js b/backend/config/db.js new file mode 100644 index 0000000..4bf5b16 --- /dev/null +++ b/backend/config/db.js @@ -0,0 +1,64 @@ +const mongoose = require('mongoose'); + +const connectDB = async () => { + try { + const mongoURI = process.env.MONGODB_URI || 'mongodb://localhost:27017/house-rental'; + console.log(`Attempting to connect to MongoDB at: ${mongoURI}`); + + const conn = await mongoose.connect(mongoURI, { + useNewUrlParser: true, + useUnifiedTopology: true, + serverSelectionTimeoutMS: 30000, + socketTimeoutMS: 45000, + connectTimeoutMS: 30000, + keepAlive: true, + keepAliveInitialDelay: 300000 + }); + + console.log(`MongoDB Connected: ${conn.connection.host}`); + + // Handle MongoDB connection errors after initial connection + mongoose.connection.on('error', err => { + console.error(`MongoDB connection error: ${err}`); + // Attempt to reconnect + setTimeout(() => { + console.log('Attempting to reconnect to MongoDB...'); + mongoose.connect(mongoURI); + }, 5000); + }); + + mongoose.connection.on('disconnected', () => { + console.warn('MongoDB disconnected. Attempting to reconnect...'); + setTimeout(() => { + console.log('Attempting to reconnect to MongoDB...'); + mongoose.connect(mongoURI); + }, 5000); + }); + + mongoose.connection.on('reconnected', () => { + console.info('MongoDB reconnected successfully'); + }); + + // Handle application termination + process.on('SIGINT', async () => { + try { + await mongoose.connection.close(); + console.log('MongoDB connection closed through app termination'); + process.exit(0); + } catch (err) { + console.error('Error closing MongoDB connection:', err); + process.exit(1); + } + }); + + } catch (error) { + console.error(`Error connecting to MongoDB: ${error.message}`); + // Log more details about the error + if (error.name === 'MongooseServerSelectionError') { + console.error('MongoDB server selection error. Please check if MongoDB is running.'); + } + process.exit(1); + } +}; + +module.exports = connectDB; diff --git a/backend/config/dialogflow-credentials.json b/backend/config/dialogflow-credentials.json new file mode 100644 index 0000000..8156840 --- /dev/null +++ b/backend/config/dialogflow-credentials.json @@ -0,0 +1,12 @@ +{ + "type": "service_account", + "project_id": "your-project-id", + "private_key_id": "your-private-key-id", + "private_key": "your-private-key", + "client_email": "your-client-email", + "client_id": "your-client-id", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "client_x509_cert_url": "your-cert-url" +} diff --git a/backend/config/dialogflow.js b/backend/config/dialogflow.js new file mode 100644 index 0000000..ecbe6f1 --- /dev/null +++ b/backend/config/dialogflow.js @@ -0,0 +1,108 @@ +const dialogflow = require('@google-cloud/dialogflow'); +const { v4: uuidv4 } = require('uuid'); + +// Initialize Dialogflow client +const sessionClient = new dialogflow.SessionsClient({ + keyFilename: process.env.GOOGLE_APPLICATION_CREDENTIALS, +}); + +const projectId = process.env.DIALOGFLOW_PROJECT_ID; + +const detectIntent = async (text, sessionId = uuidv4()) => { + const sessionPath = sessionClient.projectAgentSessionPath(projectId, sessionId); + + const request = { + session: sessionPath, + queryInput: { + text: { + text, + languageCode: 'en-US', + }, + }, + }; + + try { + const [response] = await sessionClient.detectIntent(request); + const result = response.queryResult; + + return { + text: result.fulfillmentText, + intent: result.intent.displayName, + confidence: result.intentDetectionConfidence, + parameters: result.parameters.fields, + action: result.action, + allRequiredParamsPresent: result.allRequiredParamsPresent, + }; + } catch (error) { + console.error('Error detecting intent:', error); + throw error; + } +}; + +const trainModel = async (examples) => { + const intentsClient = new dialogflow.IntentsClient({ + keyFilename: process.env.GOOGLE_APPLICATION_CREDENTIALS, + }); + + const formattedParent = intentsClient.projectAgentPath(projectId); + + try { + const [response] = await intentsClient.listIntents({ + parent: formattedParent, + }); + + const trainingPromises = examples.map(async (example) => { + const matchingIntent = response.find( + (intent) => intent.displayName === example.intent + ); + + if (matchingIntent) { + // Update existing intent + matchingIntent.trainingPhrases.push({ + parts: [{ text: example.text }], + }); + + const request = { + intent: matchingIntent, + }; + + await intentsClient.updateIntent(request); + } else { + // Create new intent + const intent = { + displayName: example.intent, + trainingPhrases: [ + { + parts: [{ text: example.text }], + }, + ], + messages: [ + { + text: { + text: [example.response], + }, + }, + ], + }; + + const request = { + parent: formattedParent, + intent, + }; + + await intentsClient.createIntent(request); + } + }); + + await Promise.all(trainingPromises); + return { success: true, message: 'Training completed successfully' }; + } catch (error) { + console.error('Error training model:', error); + throw error; + } +}; + +module.exports = { + detectIntent, + trainModel, +}; diff --git a/backend/controllers/authController.js b/backend/controllers/authController.js new file mode 100644 index 0000000..62b6624 --- /dev/null +++ b/backend/controllers/authController.js @@ -0,0 +1,350 @@ +const jwt = require('jsonwebtoken'); +const User = require('../models/User'); +const Property = require('../models/Property'); +const Booking = require('../models/Booking'); +const Review = require('../models/Review'); +const fs = require('fs'); +const path = require('path'); + +// Generate JWT Token +const generateToken = (id) => { + return jwt.sign({ id }, process.env.JWT_SECRET, { + expiresIn: '30d', + }); +}; + +// @desc Register a new user or renter +// @route POST /api/auth/register +// @access Public +exports.register = async (req, res) => { + try { + const { name, email, password, nid, address, role } = req.body; + + // Check if user already exists + const userExists = await User.findOne({ $or: [{ email }, { nid }] }); + if (userExists) { + return res.status(400).json({ + success: false, + message: 'User already exists with this email or NID', + }); + } + + // Only allow registration for 'user' and 'renter' roles + if (role && !['user', 'renter'].includes(role)) { + return res.status(403).json({ + success: false, + message: 'Invalid role specified. Only users and renters can register.', + }); + } + + // Create user + const user = await User.create({ + name, + email, + password, + nid, + address, + role: role || 'user', // Default to 'user' if role not specified + isVerified: role === 'renter' ? false : true, // Renters need verification + }); + + if (user) { + res.status(201).json({ + success: true, + data: { + _id: user._id, + name: user.name, + email: user.email, + nid: user.nid, + address: user.address, + role: user.role, + isVerified: user.isVerified, + token: generateToken(user._id), + }, + }); + } + } catch (error) { + res.status(400).json({ + success: false, + message: error.message, + }); + } +}; + +// @desc Unified login for all roles +// @route POST /api/auth/login +// @access Public +exports.login = async (req, res) => { + try { + const { email, password } = req.body; + + // Check for user email + const user = await User.findOne({ email }).select('+password'); + if (!user) { + return res.status(401).json({ + success: false, + message: 'Invalid credentials', + }); + } + + // Check password + const isMatch = await user.comparePassword(password); + if (!isMatch) { + return res.status(401).json({ + success: false, + message: 'Invalid credentials', + }); + } + + // For renters, check if they are verified + if (user.role === 'renter' && !user.isVerified) { + return res.status(403).json({ + success: false, + message: 'Your account is pending verification. Please contact admin.', + }); + } + + res.status(200).json({ + success: true, + data: { + _id: user._id, + name: user.name, + email: user.email, + nid: user.nid, + address: user.address, + role: user.role, + isVerified: user.isVerified, + token: generateToken(user._id), + }, + }); + } catch (error) { + res.status(400).json({ + success: false, + message: error.message, + }); + } +}; + +// @desc Get current logged in user +// @route GET /api/auth/me +// @access Private +exports.getMe = async (req, res) => { + try { + const user = await User.findById(req.user.id); + res.status(200).json({ + success: true, + data: user, + }); + } catch (error) { + res.status(400).json({ + success: false, + message: error.message, + }); + } +}; + +// @desc Create admin user (Super Admin only) +// @route POST /api/auth/create-admin +// @access Private/Super Admin +exports.createAdmin = async (req, res) => { + try { + // Check if the requesting user is a super admin + if (req.user.role !== 'super-admin') { + return res.status(403).json({ + success: false, + message: 'Only super admin can create admin accounts', + }); + } + + const { name, email, password, nid, address } = req.body; + + // Check if user already exists + const userExists = await User.findOne({ $or: [{ email }, { nid }] }); + if (userExists) { + return res.status(400).json({ + success: false, + message: 'User already exists with this email or NID', + }); + } + + // Create admin user + const admin = await User.create({ + name, + email, + password, + nid, + address, + role: 'admin', + isVerified: true, + }); + + res.status(201).json({ + success: true, + data: { + _id: admin._id, + name: admin.name, + email: admin.email, + nid: admin.nid, + address: admin.address, + role: admin.role, + }, + }); + } catch (error) { + res.status(400).json({ + success: false, + message: error.message, + }); + } +}; + +// @desc Update user role (Super Admin only) +// @route PUT /api/auth/update-role/:userId +// @access Private/Super Admin +exports.updateUserRole = async (req, res) => { + try { + // Check if the requesting user is a super admin + if (req.user.role !== 'super-admin') { + return res.status(403).json({ + success: false, + message: 'Only super admin can update user roles', + }); + } + + const { role } = req.body; + const { userId } = req.params; + + // Validate role + if (!['user', 'renter', 'admin'].includes(role)) { + return res.status(400).json({ + success: false, + message: 'Invalid role specified', + }); + } + + // Update user role + const user = await User.findByIdAndUpdate( + userId, + { role }, + { new: true } + ); + + if (!user) { + return res.status(404).json({ + success: false, + message: 'User not found', + }); + } + + res.status(200).json({ + success: true, + data: user, + }); + } catch (error) { + res.status(400).json({ + success: false, + message: error.message, + }); + } +}; + +// @desc Change user password +// @route POST /api/auth/change-password +// @access Private +exports.changePassword = async (req, res) => { + try { + const { currentPassword, newPassword } = req.body; + const user = await User.findById(req.user.id).select('+password'); + + if (!user) { + return res.status(404).json({ + success: false, + message: 'User not found', + }); + } + + // Check current password + const isMatch = await user.comparePassword(currentPassword); + if (!isMatch) { + return res.status(401).json({ + success: false, + message: 'Current password is incorrect', + }); + } + + // Update password + user.password = newPassword; + await user.save(); + + res.status(200).json({ + success: true, + message: 'Password updated successfully', + }); + } catch (error) { + console.error('Change password error:', error); + res.status(500).json({ + success: false, + message: 'Error changing password', + error: error.message, + }); + } +}; + +// @desc Delete user account +// @route DELETE /api/auth/delete-account +// @access Private +exports.deleteAccount = async (req, res) => { + try { + const { password } = req.body; + const user = await User.findById(req.user.id).select('+password'); + + if (!user) { + return res.status(404).json({ + success: false, + message: 'User not found', + }); + } + + // Verify password + const isMatch = await user.comparePassword(password); + if (!isMatch) { + return res.status(401).json({ + success: false, + message: 'Incorrect password', + }); + } + + // Delete user's avatar if exists + if (user.avatar) { + const avatarPath = path.join(__dirname, '../public/uploads/avatars', user.avatar); + try { + await fs.unlink(avatarPath); + } catch (error) { + console.error('Error deleting avatar:', error); + } + } + + // Delete user's properties + await Property.deleteMany({ owner: user._id }); + + // Delete user's bookings + await Booking.deleteMany({ user: user._id }); + + // Delete user's reviews + await Review.deleteMany({ user: user._id }); + + // Delete the user + await user.deleteOne(); + + res.status(200).json({ + success: true, + message: 'Account deleted successfully', + }); + } catch (error) { + console.error('Account deletion error:', error); + res.status(500).json({ + success: false, + message: 'Error deleting account', + error: error.message, + }); + } +}; diff --git a/backend/controllers/bookingController.js b/backend/controllers/bookingController.js new file mode 100644 index 0000000..e1f24df --- /dev/null +++ b/backend/controllers/bookingController.js @@ -0,0 +1,282 @@ +const Booking = require('../models/Booking'); +const Property = require('../models/Property'); +const { catchAsync, NotFoundError, ValidationError } = require('../utils/errorHandler'); + +// @desc Create new booking +// @route POST /api/bookings +// @access Private +exports.createBooking = catchAsync(async (req, res) => { + // Get property and check if it exists + const property = await Property.findById(req.body.property); + if (!property) { + throw new NotFoundError('Property not found'); + } + + // Check if property is available for the requested dates + const isAvailable = await Booking.checkAvailability( + property._id, + new Date(req.body.startDate), + new Date(req.body.endDate) + ); + + if (!isAvailable) { + throw new ValidationError('Property is not available for these dates'); + } + + // Create booking + const booking = new Booking({ + property: property._id, + tenant: req.user.id, + owner: property.owner, + startDate: req.body.startDate, + endDate: req.body.endDate, + totalAmount: req.body.totalAmount || 0 + }); + + // Calculate total amount if not provided + if (!req.body.totalAmount) { + await booking.calculateTotalAmount(); + } + + await booking.save(); + + res.status(201).json({ + success: true, + data: booking + }); +}); + +// @desc Get all bookings +// @route GET /api/bookings +// @access Private +exports.getBookings = catchAsync(async (req, res) => { + let query; + + // If user is tenant, get only their bookings + if (req.user.role === 'tenant') { + query = Booking.find({ tenant: req.user.id }); + } + // If user is owner, get only bookings for their properties + else if (req.user.role === 'owner') { + query = Booking.find({ owner: req.user.id }); + } + // If admin, get all bookings + else { + query = Booking.find(); + } + + const bookings = await query + .populate({ + path: 'property', + select: 'title location images' + }) + .populate({ + path: 'tenant', + select: 'name email phone' + }) + .populate({ + path: 'owner', + select: 'name email phone' + }); + + res.status(200).json({ + success: true, + count: bookings.length, + data: bookings + }); +}); + +// @desc Get single booking +// @route GET /api/bookings/:id +// @access Private +exports.getBooking = catchAsync(async (req, res) => { + const booking = await Booking.findById(req.params.id) + .populate({ + path: 'property', + select: 'title location images' + }) + .populate({ + path: 'tenant', + select: 'name email phone' + }) + .populate({ + path: 'owner', + select: 'name email phone' + }); + + if (!booking) { + throw new NotFoundError(`Booking not found with id of ${req.params.id}`); + } + + // Make sure user is booking owner or property owner + if ( + booking.tenant.toString() !== req.user.id && + booking.owner.toString() !== req.user.id && + req.user.role !== 'admin' + ) { + throw new ValidationError('Not authorized to access this booking'); + } + + res.status(200).json({ + success: true, + data: booking + }); +}); + +// @desc Update booking +// @route PUT /api/bookings/:id +// @access Private +exports.updateBooking = catchAsync(async (req, res) => { + let booking = await Booking.findById(req.params.id); + + if (!booking) { + throw new NotFoundError(`Booking not found with id of ${req.params.id}`); + } + + // Make sure user is booking tenant + if ( + booking.tenant.toString() !== req.user.id && + req.user.role !== 'admin' + ) { + throw new ValidationError('Not authorized to update this booking'); + } + + // If dates are being updated, check availability + if (req.body.startDate || req.body.endDate) { + const isAvailable = await Booking.checkAvailability( + booking.property, + new Date(req.body.startDate || booking.startDate), + new Date(req.body.endDate || booking.endDate), + booking._id + ); + + if (!isAvailable) { + throw new ValidationError('Property is not available for these dates'); + } + } + + booking = await Booking.findByIdAndUpdate(req.params.id, req.body, { + new: true, + runValidators: true + }); + + res.status(200).json({ + success: true, + data: booking + }); +}); + +// @desc Delete booking +// @route DELETE /api/bookings/:id +// @access Private +exports.deleteBooking = catchAsync(async (req, res) => { + const booking = await Booking.findById(req.params.id); + + if (!booking) { + throw new NotFoundError(`Booking not found with id of ${req.params.id}`); + } + + // Make sure user is booking tenant or admin + if ( + booking.tenant.toString() !== req.user.id && + req.user.role !== 'admin' + ) { + throw new ValidationError('Not authorized to delete this booking'); + } + + await booking.remove(); + + res.status(200).json({ + success: true, + data: {} + }); +}); + +// @desc Update booking status +// @route PUT /api/bookings/:id/status +// @access Private +exports.updateBookingStatus = catchAsync(async (req, res) => { + const { status } = req.body; + let booking = await Booking.findById(req.params.id); + + if (!booking) { + throw new NotFoundError(`Booking not found with id of ${req.params.id}`); + } + + // Make sure user is property owner or admin + if ( + booking.owner.toString() !== req.user.id && + req.user.role !== 'admin' + ) { + throw new ValidationError('Not authorized to update booking status'); + } + + booking.status = status; + await booking.save(); + + res.status(200).json({ + success: true, + data: booking + }); +}); + +// @desc Add booking message +// @route POST /api/bookings/:id/messages +// @access Private +exports.addBookingMessage = catchAsync(async (req, res) => { + const booking = await Booking.findById(req.params.id); + + if (!booking) { + throw new NotFoundError(`Booking not found with id of ${req.params.id}`); + } + + // Make sure user is booking owner or property owner + if ( + booking.tenant.toString() !== req.user.id && + booking.owner.toString() !== req.user.id && + req.user.role !== 'admin' + ) { + throw new ValidationError('Not authorized to add messages to this booking'); + } + + booking.messages.push({ + sender: req.user.id, + message: req.body.message + }); + + await booking.save(); + + res.status(200).json({ + success: true, + data: booking + }); +}); + +// @desc Get booking messages +// @route GET /api/bookings/:id/messages +// @access Private +exports.getBookingMessages = catchAsync(async (req, res) => { + const booking = await Booking.findById(req.params.id) + .populate({ + path: 'messages.sender', + select: 'name' + }); + + if (!booking) { + throw new NotFoundError(`Booking not found with id of ${req.params.id}`); + } + + // Make sure user is booking owner or property owner + if ( + booking.tenant.toString() !== req.user.id && + booking.owner.toString() !== req.user.id && + req.user.role !== 'admin' + ) { + throw new ValidationError('Not authorized to view messages'); + } + + res.status(200).json({ + success: true, + data: booking.messages + }); +}); diff --git a/backend/controllers/chatbotController.js b/backend/controllers/chatbotController.js new file mode 100644 index 0000000..4fdd13f --- /dev/null +++ b/backend/controllers/chatbotController.js @@ -0,0 +1,44 @@ +const { catchAsync } = require('../utils/errorHandler'); + +// Process user message and return a response +exports.processMessage = catchAsync(async (req, res) => { + const { message } = req.body; + + // Basic responses for demonstration + const responses = [ + "I can help you find the perfect rental property in Bangladesh.", + "Would you like to search for properties in a specific area?", + "I can show you properties within your budget.", + "Let me know what features you're looking for in a rental property.", + "I can help you schedule property viewings.", + ]; + + const randomResponse = responses[Math.floor(Math.random() * responses.length)]; + + res.status(200).json({ + success: true, + data: { + message: randomResponse, + timestamp: new Date(), + } + }); +}); + +// Save user feedback about chatbot responses +exports.saveFeedback = catchAsync(async (req, res) => { + const { userMessage, botResponse, isHelpful } = req.body; + + // Log feedback for now, can be stored in DB later + console.log('Feedback received:', { + userId: req.user._id, + userMessage, + botResponse, + isHelpful, + timestamp: new Date(), + }); + + res.status(200).json({ + success: true, + message: 'Feedback received successfully' + }); +}); diff --git a/backend/controllers/imageController.js b/backend/controllers/imageController.js new file mode 100644 index 0000000..79210f8 --- /dev/null +++ b/backend/controllers/imageController.js @@ -0,0 +1,106 @@ +const Property = require('../models/Property'); +const { processImage, deleteImage } = require('../utils/imageUpload'); + +// @desc Upload property images +// @route POST /api/properties/:id/images +// @access Private/Renter +exports.uploadPropertyImages = async (req, res) => { + try { + const property = await Property.findById(req.params.id); + + if (!property) { + return res.status(404).json({ + success: false, + message: 'Property not found' + }); + } + + // Check ownership + if (property.owner.toString() !== req.user.id && req.user.role !== 'admin') { + return res.status(401).json({ + success: false, + message: 'Not authorized to upload images for this property' + }); + } + + if (!req.files || req.files.length === 0) { + return res.status(400).json({ + success: false, + message: 'Please upload at least one image' + }); + } + + const processedImages = []; + for (const file of req.files) { + const processedImage = await processImage(file); + processedImages.push({ + url: processedImage.url, + caption: req.body.captions ? req.body.captions[file.originalname] : '' + }); + } + + // Add new images to property + property.images.push(...processedImages); + await property.save(); + + res.status(200).json({ + success: true, + data: property.images + }); + } catch (error) { + res.status(400).json({ + success: false, + message: error.message + }); + } +}; + +// @desc Delete property image +// @route DELETE /api/properties/:id/images/:imageId +// @access Private/Renter +exports.deletePropertyImage = async (req, res) => { + try { + const property = await Property.findById(req.params.id); + + if (!property) { + return res.status(404).json({ + success: false, + message: 'Property not found' + }); + } + + // Check ownership + if (property.owner.toString() !== req.user.id && req.user.role !== 'admin') { + return res.status(401).json({ + success: false, + message: 'Not authorized to delete images for this property' + }); + } + + const image = property.images.id(req.params.imageId); + if (!image) { + return res.status(404).json({ + success: false, + message: 'Image not found' + }); + } + + // Delete image file + const filename = image.url.split('/').pop(); + deleteImage(filename); + + // Remove image from property + image.remove(); + await property.save(); + + res.status(200).json({ + success: true, + data: property.images + }); + } catch (error) { + res.status(400).json({ + success: false, + message: error.message + }); + } +}; diff --git a/backend/controllers/notificationController.js b/backend/controllers/notificationController.js new file mode 100644 index 0000000..64175eb --- /dev/null +++ b/backend/controllers/notificationController.js @@ -0,0 +1,141 @@ +const asyncHandler = require('express-async-handler'); +const { validationResult } = require('express-validator'); +const Notification = require('../models/Notification'); +const User = require('../models/User'); + +// @desc Get all notifications for a user +// @route GET /api/notifications +// @access Private +exports.getNotifications = asyncHandler(async (req, res) => { + const page = parseInt(req.query.page) || 1; + const limit = parseInt(req.query.limit) || 10; + const startIndex = (page - 1) * limit; + + const notifications = await Notification.find({ user: req.user.id }) + .sort({ createdAt: -1 }) + .skip(startIndex) + .limit(limit) + .populate('sender', 'name email'); + + const total = await Notification.countDocuments({ user: req.user.id }); + + res.json({ + success: true, + data: { + notifications, + pagination: { + page, + limit, + total, + pages: Math.ceil(total / limit) + } + } + }); +}); + +// @desc Get user's notification preferences +// @route GET /api/notifications/preferences +// @access Private +exports.getPreferences = asyncHandler(async (req, res) => { + const user = await User.findById(req.user.id) + .select('preferences.notifications'); + + if (!user) { + res.status(404); + throw new Error('User not found'); + } + + res.json({ + success: true, + data: user.preferences?.notifications || {} + }); +}); + +// @desc Update notification preferences +// @route PUT /api/notifications/preferences +// @access Private +exports.updatePreferences = asyncHandler(async (req, res) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + res.status(400); + throw new Error('Invalid input data'); + } + + const user = await User.findById(req.user.id); + if (!user) { + res.status(404); + throw new Error('User not found'); + } + + // Initialize notifications preferences if they don't exist + if (!user.preferences) { + user.preferences = {}; + } + if (!user.preferences.notifications) { + user.preferences.notifications = {}; + } + + // Update only provided preferences + const validPreferences = [ + 'email', 'push', 'sms', 'sound', + 'bookings', 'messages', 'propertyUpdates', 'marketing' + ]; + + for (const pref of validPreferences) { + if (req.body[pref] !== undefined) { + user.preferences.notifications[pref] = req.body[pref]; + } + } + + await user.save(); + + res.json({ + success: true, + data: user.preferences.notifications + }); +}); + +// @desc Mark notification as read +// @route PUT /api/notifications/:id/read +// @access Private +exports.markAsRead = asyncHandler(async (req, res) => { + const notification = await Notification.findOne({ + _id: req.params.id, + user: req.user.id + }); + + if (!notification) { + res.status(404); + throw new Error('Notification not found'); + } + + notification.isRead = true; + await notification.save(); + + res.json({ + success: true, + data: notification + }); +}); + +// @desc Delete a notification +// @route DELETE /api/notifications/:id +// @access Private +exports.deleteNotification = asyncHandler(async (req, res) => { + const notification = await Notification.findOne({ + _id: req.params.id, + user: req.user.id + }); + + if (!notification) { + res.status(404); + throw new Error('Notification not found'); + } + + await notification.remove(); + + res.json({ + success: true, + data: {} + }); +}); diff --git a/backend/controllers/propertyController.js b/backend/controllers/propertyController.js new file mode 100644 index 0000000..2a327f6 --- /dev/null +++ b/backend/controllers/propertyController.js @@ -0,0 +1,426 @@ +const Property = require('../models/Property'); +const { getValidImageUrl } = require('../utils/imagePlaceholder'); + +// @desc Get all properties with filtering, sorting, and pagination +// @route GET /api/properties +// @access Public +exports.getProperties = async (req, res) => { + try { + // Copy req.query + const reqQuery = { ...req.query }; + + // Fields to exclude from filtering + const removeFields = ['select', 'sort', 'page', 'limit']; + removeFields.forEach(param => delete reqQuery[param]); + + // Create query string + let queryStr = JSON.stringify(reqQuery); + + // Create operators ($gt, $gte, etc) + queryStr = queryStr.replace(/\b(gt|gte|lt|lte|in)\b/g, match => `$${match}`); + + // Finding resource + let query = Property.find(JSON.parse(queryStr)); + + // Select Fields + if (req.query.select) { + const fields = req.query.select.split(',').join(' '); + query = query.select(fields); + } + + // Sort + if (req.query.sort) { + const sortBy = req.query.sort.split(',').join(' '); + query = query.sort(sortBy); + } else { + query = query.sort('-createdAt'); + } + + // Pagination + const page = parseInt(req.query.page, 10) || 1; + const limit = parseInt(req.query.limit, 10) || 10; + const startIndex = (page - 1) * limit; + const endIndex = page * limit; + const total = await Property.countDocuments(JSON.parse(queryStr)); + + query = query.skip(startIndex).limit(limit); + + // Populate owner details + query = query.populate({ + path: 'owner', + select: 'firstName lastName email phone' + }); + + // Execute query + const properties = await query; + + // Process images for each property + const processedProperties = await Promise.all(properties.map(async (property) => { + const propertyObj = property.toObject(); + + // Process property images + if (propertyObj.images && propertyObj.images.length > 0) { + propertyObj.images = await Promise.all(propertyObj.images.map(async (image) => ({ + url: await getValidImageUrl(image.url, 'property', propertyObj.propertyType), + alt: image.alt + }))); + } else { + // Add default image if no images exist + propertyObj.images = [{ + url: await getValidImageUrl(null, 'property', propertyObj.propertyType), + alt: `${propertyObj.propertyType} Default Image` + }]; + } + + return propertyObj; + })); + + // Pagination result + const pagination = {}; + + if (endIndex < total) { + pagination.next = { + page: page + 1, + limit + }; + } + + if (startIndex > 0) { + pagination.prev = { + page: page - 1, + limit + }; + } + + res.status(200).json({ + success: true, + count: processedProperties.length, + pagination, + data: processedProperties + }); + } catch (error) { + res.status(400).json({ + success: false, + message: error.message + }); + } +}; + +// @desc Get single property +// @route GET /api/properties/:id +// @access Public +exports.getProperty = async (req, res) => { + try { + const property = await Property.findById(req.params.id).populate({ + path: 'owner', + select: 'firstName lastName email phone' + }); + + if (!property) { + return res.status(404).json({ + success: false, + message: 'Property not found' + }); + } + + // Process property images + const propertyObj = property.toObject(); + if (propertyObj.images && propertyObj.images.length > 0) { + propertyObj.images = await Promise.all(propertyObj.images.map(async (image) => ({ + url: await getValidImageUrl(image.url, 'property', propertyObj.propertyType), + alt: image.alt + }))); + } else { + propertyObj.images = [{ + url: await getValidImageUrl(null, 'property', propertyObj.propertyType), + alt: `${propertyObj.propertyType} Default Image` + }]; + } + + res.status(200).json({ + success: true, + data: propertyObj + }); + } catch (error) { + res.status(400).json({ + success: false, + message: error.message + }); + } +}; + +// @desc Create new property +// @route POST /api/properties +// @access Private/Renter +exports.createProperty = async (req, res) => { + try { + // Add owner to req.body + req.body.owner = req.user.id; + + // Validate Bangladesh-specific fields + if (!req.body.address.division || !req.body.address.district || !req.body.address.thana) { + return res.status(400).json({ + success: false, + message: 'Please provide complete Bangladesh address (division, district, and thana)' + }); + } + + // Validate postal code format (Bangladesh) + if (!/^\d{4}$/.test(req.body.address.postCode)) { + return res.status(400).json({ + success: false, + message: 'Please provide a valid Bangladesh postal code (4 digits)' + }); + } + + // Set default country + req.body.address.country = 'Bangladesh'; + + // Convert price to BDT if not specified + if (!req.body.price.currency) { + req.body.price.currency = 'BDT'; + } + + // Process images + if (req.body.images && req.body.images.length > 0) { + req.body.images = await Promise.all(req.body.images.map(async (image) => ({ + url: await getValidImageUrl(image.url, 'property', req.body.propertyType), + alt: image.alt || `${req.body.propertyType} Image` + }))); + } else { + req.body.images = [{ + url: await getValidImageUrl(null, 'property', req.body.propertyType), + alt: `${req.body.propertyType} Default Image` + }]; + } + + const property = await Property.create(req.body); + + res.status(201).json({ + success: true, + data: property + }); + } catch (error) { + res.status(400).json({ + success: false, + message: error.message + }); + } +}; + +// @desc Update property +// @route PUT /api/properties/:id +// @access Private/Renter +exports.updateProperty = async (req, res) => { + try { + let property = await Property.findById(req.params.id); + + if (!property) { + return res.status(404).json({ + success: false, + message: 'Property not found' + }); + } + + // Make sure user is property owner + if (property.owner.toString() !== req.user.id && req.user.role !== 'admin') { + return res.status(401).json({ + success: false, + message: 'Not authorized to update this property' + }); + } + + // Validate Bangladesh-specific fields + if (req.body.address && (!req.body.address.division || !req.body.address.district || !req.body.address.thana)) { + return res.status(400).json({ + success: false, + message: 'Please provide complete Bangladesh address (division, district, and thana)' + }); + } + + // Validate postal code format (Bangladesh) + if (req.body.address && req.body.address.postCode && !/^\d{4}$/.test(req.body.address.postCode)) { + return res.status(400).json({ + success: false, + message: 'Please provide a valid Bangladesh postal code (4 digits)' + }); + } + + // Set default country + if (req.body.address) { + req.body.address.country = 'Bangladesh'; + } + + // Convert price to BDT if not specified + if (req.body.price && !req.body.price.currency) { + req.body.price.currency = 'BDT'; + } + + // Process images + if (req.body.images && req.body.images.length > 0) { + req.body.images = await Promise.all(req.body.images.map(async (image) => ({ + url: await getValidImageUrl(image.url, 'property', req.body.propertyType), + alt: image.alt || `${req.body.propertyType} Image` + }))); + } else if (req.body.images && req.body.images.length === 0) { + req.body.images = [{ + url: await getValidImageUrl(null, 'property', req.body.propertyType), + alt: `${req.body.propertyType} Default Image` + }]; + } + + property = await Property.findByIdAndUpdate(req.params.id, req.body, { + new: true, + runValidators: true + }); + + res.status(200).json({ + success: true, + data: property + }); + } catch (error) { + res.status(400).json({ + success: false, + message: error.message + }); + } +}; + +// @desc Delete property +// @route DELETE /api/properties/:id +// @access Private/Renter +exports.deleteProperty = async (req, res) => { + try { + const property = await Property.findById(req.params.id); + + if (!property) { + return res.status(404).json({ + success: false, + message: 'Property not found' + }); + } + + // Make sure user is property owner + if (property.owner.toString() !== req.user.id && req.user.role !== 'admin') { + return res.status(401).json({ + success: false, + message: 'Not authorized to delete this property' + }); + } + + await property.deleteOne(); + + res.status(200).json({ + success: true, + data: {} + }); + } catch (error) { + res.status(400).json({ + success: false, + message: error.message + }); + } +}; + +// @desc Add property rating +// @route POST /api/properties/:id/ratings +// @access Private +exports.addPropertyRating = async (req, res) => { + try { + const property = await Property.findById(req.params.id); + + if (!property) { + return res.status(404).json({ + success: false, + message: 'Property not found' + }); + } + + // Check if user already rated + const existingRating = property.ratings.find( + rating => rating.user.toString() === req.user.id + ); + + if (existingRating) { + return res.status(400).json({ + success: false, + message: 'You have already rated this property' + }); + } + + property.ratings.push({ + rating: req.body.rating, + review: req.body.review, + user: req.user.id + }); + + await property.save(); + + res.status(200).json({ + success: true, + data: property + }); + } catch (error) { + res.status(400).json({ + success: false, + message: error.message + }); + } +}; + +// @desc Get properties within radius +// @route GET /api/properties/radius/:zipcode/:distance +// @access Public +exports.getPropertiesInRadius = async (req, res) => { + try { + const { zipcode, distance } = req.params; + + // Get lat/lng from geocoder + const loc = await geocoder.geocode(zipcode); + const lat = loc[0].latitude; + const lng = loc[0].longitude; + + // Calc radius using radians + // Divide dist by radius of Earth + // Earth Radius = 3,963 miles / 6,378 km + const radius = distance / 3963; + + const properties = await Property.find({ + location: { + $geoWithin: { $centerSphere: [[lng, lat], radius] } + } + }); + + // Process images for each property + const processedProperties = await Promise.all(properties.map(async (property) => { + const propertyObj = property.toObject(); + + // Process property images + if (propertyObj.images && propertyObj.images.length > 0) { + propertyObj.images = await Promise.all(propertyObj.images.map(async (image) => ({ + url: await getValidImageUrl(image.url, 'property', propertyObj.propertyType), + alt: image.alt + }))); + } else { + // Add default image if no images exist + propertyObj.images = [{ + url: await getValidImageUrl(null, 'property', propertyObj.propertyType), + alt: `${propertyObj.propertyType} Default Image` + }]; + } + + return propertyObj; + })); + + res.status(200).json({ + success: true, + count: processedProperties.length, + data: processedProperties + }); + } catch (error) { + res.status(400).json({ + success: false, + message: error.message + }); + } +}; diff --git a/backend/controllers/reviewController.js b/backend/controllers/reviewController.js new file mode 100644 index 0000000..5e428d7 --- /dev/null +++ b/backend/controllers/reviewController.js @@ -0,0 +1,114 @@ +const { catchAsync, NotFoundError, AuthorizationError } = require('../utils/errorHandler'); +const Review = require('../models/Review'); + +// Get all reviews +exports.getReviews = catchAsync(async (req, res) => { + const reviews = await Review.find() + .populate('userId', 'name avatar') + .populate('propertyId', 'title images location'); + + res.status(200).json({ + success: true, + count: reviews.length, + data: reviews + }); +}); + +// Get single review +exports.getReview = catchAsync(async (req, res) => { + const review = await Review.findById(req.params.id) + .populate('userId', 'name avatar') + .populate('propertyId', 'title images location'); + + if (!review) { + throw new NotFoundError('Review not found'); + } + + res.status(200).json({ + success: true, + data: review + }); +}); + +// Create review +exports.createReview = catchAsync(async (req, res) => { + // Add user and property to req.body + req.body.userId = req.user.id; + req.body.propertyId = req.params.propertyId; + + const review = await Review.create(req.body); + + res.status(201).json({ + success: true, + data: review + }); +}); + +// Update review +exports.updateReview = catchAsync(async (req, res) => { + let review = await Review.findById(req.params.id); + + if (!review) { + throw new NotFoundError('Review not found'); + } + + // Make sure review belongs to user or user is admin + if (review.userId.toString() !== req.user.id && req.user.role !== 'admin') { + throw new AuthorizationError('Not authorized to update this review'); + } + + review = await Review.findByIdAndUpdate(req.params.id, req.body, { + new: true, + runValidators: true + }); + + res.status(200).json({ + success: true, + data: review + }); +}); + +// Delete review +exports.deleteReview = catchAsync(async (req, res) => { + const review = await Review.findById(req.params.id); + + if (!review) { + throw new NotFoundError('Review not found'); + } + + // Make sure review belongs to user or user is admin + if (review.userId.toString() !== req.user.id && req.user.role !== 'admin') { + throw new AuthorizationError('Not authorized to delete this review'); + } + + await review.remove(); + + res.status(200).json({ + success: true, + data: {} + }); +}); + +// Get reviews for a property +exports.getPropertyReviews = catchAsync(async (req, res) => { + const reviews = await Review.find({ propertyId: req.params.propertyId }) + .populate('userId', 'name avatar'); + + res.status(200).json({ + success: true, + count: reviews.length, + data: reviews + }); +}); + +// Get reviews by a user +exports.getUserReviews = catchAsync(async (req, res) => { + const reviews = await Review.find({ userId: req.params.userId }) + .populate('propertyId', 'title images location'); + + res.status(200).json({ + success: true, + count: reviews.length, + data: reviews + }); +}); diff --git a/backend/controllers/settingsController.js b/backend/controllers/settingsController.js new file mode 100644 index 0000000..dcf0c33 --- /dev/null +++ b/backend/controllers/settingsController.js @@ -0,0 +1,331 @@ +const asyncHandler = require('express-async-handler'); +const User = require('../models/User'); +const { validatePassword } = require('../utils/validation'); +const { + generateTwoFactorSecret, + verifyTwoFactorToken, + generateQRCode, + getClientInfo +} = require('../utils/security'); + +// Profile Settings +// @desc Update user profile +// @route PUT /api/settings/profile +// @access Private +exports.updateProfile = asyncHandler(async (req, res) => { + const { + firstName, + lastName, + phone, + address, + bio, + occupation, + website + } = req.body; + + const user = await User.findById(req.user.id); + if (!user) { + res.status(404); + throw new Error('User not found'); + } + + // Update user profile + user.firstName = firstName; + user.lastName = lastName; + user.phone = phone; + user.address = address; + user.bio = bio; + user.occupation = occupation; + user.website = website; + user.updatedAt = new Date(); + + await user.save(); + + res.json({ + success: true, + data: { + firstName: user.firstName, + lastName: user.lastName, + email: user.email, + phone: user.phone, + address: user.address, + bio: user.bio, + occupation: user.occupation, + website: user.website, + profileImage: user.profileImage + } + }); +}); + +// Profile Image +// @desc Update profile image +// @route PUT /api/settings/profile/image +// @access Private +exports.updateProfileImage = asyncHandler(async (req, res) => { + if (!req.file) { + res.status(400); + throw new Error('No image file provided'); + } + + const user = await User.findById(req.user.id); + if (!user) { + res.status(404); + throw new Error('User not found'); + } + + // Update profile image path + user.profileImage = req.file.path; + user.updatedAt = new Date(); + await user.save(); + + res.json({ + success: true, + data: { + profileImage: user.profileImage + } + }); +}); + +// Security Settings +// @desc Update password +// @route PUT /api/settings/security/password +// @access Private +exports.updatePassword = asyncHandler(async (req, res) => { + const { currentPassword, newPassword } = req.body; + + const user = await User.findById(req.user.id); + if (!user) { + res.status(404); + throw new Error('User not found'); + } + + // Verify current password + const isMatch = await user.comparePassword(currentPassword); + if (!isMatch) { + res.status(400); + throw new Error('Current password is incorrect'); + } + + // Validate new password + if (!validatePassword(newPassword)) { + res.status(400); + throw new Error('Password must be at least 8 characters long and contain uppercase, lowercase, number and special character'); + } + + // Update password + user.password = newPassword; + await user.save(); + + // Log security event + const clientInfo = getClientInfo(req); + await user.logSecurityEvent('password_change', clientInfo); + + res.json({ + success: true, + message: 'Password updated successfully' + }); +}); + +exports.updateSecuritySettings = asyncHandler(async (req, res) => { + const { + twoFactorEnabled, + emailNotifications, + loginAlerts, + requirePasswordChange + } = req.body; + + const user = await User.findById(req.user.id); + if (!user) { + res.status(404); + throw new Error('User not found'); + } + + // Handle 2FA enabling/disabling + if (twoFactorEnabled !== undefined && twoFactorEnabled !== user.securitySettings?.twoFactorEnabled) { + if (twoFactorEnabled) { + const secret = generateTwoFactorSecret(); + const qrCode = await generateQRCode(secret); + user.securitySettings = { + ...user.securitySettings, + twoFactorEnabled: true, + twoFactorSecret: secret.base32, + twoFactorQR: qrCode + }; + } else { + user.securitySettings = { + ...user.securitySettings, + twoFactorEnabled: false, + twoFactorSecret: undefined, + twoFactorQR: undefined + }; + } + } + + // Update other security settings + user.securitySettings = { + ...user.securitySettings, + emailNotifications, + loginAlerts, + requirePasswordChange + }; + + await user.save(); + + // Log security event + const clientInfo = getClientInfo(req); + await user.logSecurityEvent('security_settings_update', clientInfo); + + res.json({ + success: true, + data: { + twoFactorEnabled: user.securitySettings.twoFactorEnabled, + emailNotifications: user.securitySettings.emailNotifications, + loginAlerts: user.securitySettings.loginAlerts, + requirePasswordChange: user.securitySettings.requirePasswordChange, + twoFactorQR: user.securitySettings.twoFactorQR + } + }); +}); + +exports.getSecurityLogs = asyncHandler(async (req, res) => { + const user = await User.findById(req.user.id); + if (!user) { + res.status(404); + throw new Error('User not found'); + } + + res.json({ + success: true, + data: user.securityLogs + }); +}); + +// Notification Settings +// @desc Update notification preferences +// @route PUT /api/settings/notifications +// @access Private +exports.updateNotificationPreferences = asyncHandler(async (req, res) => { + const preferences = req.body; + + const user = await User.findById(req.user.id); + if (!user) { + res.status(404); + throw new Error('User not found'); + } + + // Initialize preferences if they don't exist + user.preferences = user.preferences || {}; + user.preferences.notifications = { + ...user.preferences.notifications, + ...preferences + }; + + await user.save(); + + res.json({ + success: true, + data: user.preferences.notifications + }); +}); + +// Theme Settings +// @desc Update theme +// @route PUT /api/settings/theme +// @access Private +exports.updateTheme = asyncHandler(async (req, res) => { + const { theme } = req.body; + + const user = await User.findById(req.user.id); + if (!user) { + res.status(404); + throw new Error('User not found'); + } + + if (!['light', 'dark', 'system'].includes(theme)) { + res.status(400); + throw new Error('Invalid theme option'); + } + + user.preferences = user.preferences || {}; + user.preferences.theme = theme; + await user.save(); + + res.json({ + success: true, + data: { + theme: user.preferences.theme + } + }); +}); + +// Language Settings +// @desc Update language +// @route PUT /api/settings/language +// @access Private +exports.updateLanguage = asyncHandler(async (req, res) => { + const { language } = req.body; + + const user = await User.findById(req.user.id); + if (!user) { + res.status(404); + throw new Error('User not found'); + } + + if (!['en', 'bn'].includes(language)) { + res.status(400); + throw new Error('Invalid language option'); + } + + user.preferences = user.preferences || {}; + user.preferences.language = language; + await user.save(); + + res.json({ + success: true, + data: { + language: user.preferences.language + } + }); +}); + +// Get All Settings +// @desc Get all settings +// @route GET /api/settings +// @access Private +exports.getAllSettings = asyncHandler(async (req, res) => { + const user = await User.findById(req.user.id) + .select('-password -securitySettings.twoFactorSecret'); + + if (!user) { + res.status(404); + throw new Error('User not found'); + } + + res.json({ + success: true, + data: { + profile: { + firstName: user.firstName, + lastName: user.lastName, + email: user.email, + phone: user.phone, + address: user.address, + bio: user.bio, + occupation: user.occupation, + website: user.website, + profileImage: user.profileImage + }, + security: { + twoFactorEnabled: user.securitySettings?.twoFactorEnabled, + emailNotifications: user.securitySettings?.emailNotifications, + loginAlerts: user.securitySettings?.loginAlerts, + requirePasswordChange: user.securitySettings?.requirePasswordChange + }, + notifications: user.preferences?.notifications, + preferences: { + theme: user.preferences?.theme || 'system', + language: user.preferences?.language || 'en' + } + } + }); +}); diff --git a/backend/controllers/uploadController.js b/backend/controllers/uploadController.js new file mode 100644 index 0000000..373f305 --- /dev/null +++ b/backend/controllers/uploadController.js @@ -0,0 +1,72 @@ +const path = require('path'); +const sharp = require('sharp'); +const User = require('../models/User'); +const fs = require('fs').promises; + +// @desc Upload user avatar +// @route POST /api/upload/avatar +// @access Private +exports.uploadAvatar = async (req, res) => { + try { + if (!req.file) { + return res.status(400).json({ + success: false, + message: 'Please upload an image file', + }); + } + + const user = await User.findById(req.user.id); + if (!user) { + return res.status(404).json({ + success: false, + message: 'User not found', + }); + } + + // Create uploads directory if it doesn't exist + const uploadsDir = path.join(__dirname, '../public/uploads/avatars'); + await fs.mkdir(uploadsDir, { recursive: true }); + + // Generate unique filename + const filename = `avatar-${user._id}-${Date.now()}.webp`; + const filepath = path.join(uploadsDir, filename); + + // Process and optimize image + await sharp(req.file.buffer) + .resize(200, 200, { // Resize to standard avatar size + fit: 'cover', + position: 'center' + }) + .webp({ quality: 80 }) // Convert to WebP format + .toFile(filepath); + + // Delete old avatar if exists + if (user.avatar) { + const oldAvatarPath = path.join(uploadsDir, user.avatar); + try { + await fs.unlink(oldAvatarPath); + } catch (error) { + console.error('Error deleting old avatar:', error); + } + } + + // Update user avatar in database + user.avatar = filename; + await user.save(); + + res.status(200).json({ + success: true, + data: { + avatar: `/uploads/avatars/${filename}`, + }, + message: 'Avatar uploaded successfully', + }); + } catch (error) { + console.error('Avatar upload error:', error); + res.status(500).json({ + success: false, + message: 'Error uploading avatar', + error: error.message, + }); + } +}; diff --git a/backend/controllers/userController.js b/backend/controllers/userController.js new file mode 100644 index 0000000..e82b135 --- /dev/null +++ b/backend/controllers/userController.js @@ -0,0 +1,180 @@ +const User = require('../models/User'); + +// @desc Get all users +// @route GET /api/users +// @access Private/Admin +exports.getUsers = async (req, res) => { + try { + const page = parseInt(req.query.page, 10) || 1; + const limit = parseInt(req.query.limit, 10) || 10; + const startIndex = (page - 1) * limit; + + const users = await User.find() + .select('-password') + .skip(startIndex) + .limit(limit) + .sort('-createdAt'); + + const total = await User.countDocuments(); + + res.status(200).json({ + success: true, + data: users, + pagination: { + page, + limit, + total, + pages: Math.ceil(total / limit) + } + }); + } catch (error) { + res.status(400).json({ + success: false, + message: error.message + }); + } +}; + +// @desc Get single user +// @route GET /api/users/:id +// @access Private/Admin +exports.getUser = async (req, res) => { + try { + const user = await User.findById(req.params.id).select('-password'); + + if (!user) { + return res.status(404).json({ + success: false, + message: 'User not found' + }); + } + + res.status(200).json({ + success: true, + data: user + }); + } catch (error) { + res.status(400).json({ + success: false, + message: error.message + }); + } +}; + +// @desc Update user +// @route PUT /api/users/:id +// @access Private/Admin +exports.updateUser = async (req, res) => { + try { + const { name, email, role, address } = req.body; + + const user = await User.findById(req.params.id); + + if (!user) { + return res.status(404).json({ + success: false, + message: 'User not found' + }); + } + + // Update fields + if (name) user.name = name; + if (email) user.email = email; + if (role) user.role = role; + if (address) user.address = address; + + const updatedUser = await user.save(); + + res.status(200).json({ + success: true, + data: updatedUser.toPublicProfile() + }); + } catch (error) { + res.status(400).json({ + success: false, + message: error.message + }); + } +}; + +// @desc Delete user +// @route DELETE /api/users/:id +// @access Private/Admin +exports.deleteUser = async (req, res) => { + try { + const user = await User.findById(req.params.id); + + if (!user) { + return res.status(404).json({ + success: false, + message: 'User not found' + }); + } + + // Prevent deletion of super-admin + if (user.role === 'super-admin') { + return res.status(403).json({ + success: false, + message: 'Super admin cannot be deleted' + }); + } + + await user.deleteOne(); + + res.status(200).json({ + success: true, + data: {} + }); + } catch (error) { + res.status(400).json({ + success: false, + message: error.message + }); + } +}; + +// @desc Update user role +// @route PATCH /api/users/:id/role +// @access Private/Super-Admin +exports.updateUserRole = async (req, res) => { + try { + const { role } = req.body; + + if (!role) { + return res.status(400).json({ + success: false, + message: 'Role is required' + }); + } + + const user = await User.findById(req.params.id); + + if (!user) { + return res.status(404).json({ + success: false, + message: 'User not found' + }); + } + + // Prevent changing super-admin role + if (user.role === 'super-admin' && req.user.role !== 'super-admin') { + return res.status(403).json({ + success: false, + message: 'Super admin role cannot be modified' + }); + } + + user.role = role; + await user.save(); + + res.status(200).json({ + success: true, + data: user.toPublicProfile() + }); + } catch (error) { + res.status(400).json({ + success: false, + message: error.message + }); + } +}; diff --git a/backend/credentials.txt b/backend/credentials.txt new file mode 100644 index 0000000..ae396e0 --- /dev/null +++ b/backend/credentials.txt @@ -0,0 +1,28 @@ + +House Rental Platform - User Credentials +======================================= + +Super Admin +---------- +Email: superadmin@houserental.com +Password: SuperAdmin@123 + +Admin +----- +Email: admin@houserental.com +Password: Admin@123 + +Renter +------ +Email: renter@houserental.com +Password: Renter@123 + +Users +----- +1. Email: alice@example.com + Password: User@123 + +2. Email: bob@example.com + Password: User@123 + +Note: Please change these passwords after first login for security purposes. diff --git a/backend/dev.ps1 b/backend/dev.ps1 new file mode 100644 index 0000000..f4a5ac3 --- /dev/null +++ b/backend/dev.ps1 @@ -0,0 +1,11 @@ +Write-Host "🔄 Stopping any running Node.js processes..." -ForegroundColor Yellow +Get-Process node -ErrorAction SilentlyContinue | Stop-Process -Force + +Write-Host "🧹 Cleaning node_modules..." -ForegroundColor Yellow +Remove-Item -Path "node_modules" -Recurse -Force -ErrorAction SilentlyContinue + +Write-Host "📦 Installing dependencies..." -ForegroundColor Yellow +npm install + +Write-Host "🚀 Starting development server..." -ForegroundColor Green +npm run dev diff --git a/backend/middleware/async.js b/backend/middleware/async.js new file mode 100644 index 0000000..2490677 --- /dev/null +++ b/backend/middleware/async.js @@ -0,0 +1,5 @@ +// Async handler to wrap async route handlers and catch errors +const asyncHandler = fn => (req, res, next) => + Promise.resolve(fn(req, res, next)).catch(next); + +module.exports = asyncHandler; diff --git a/backend/middleware/asyncHandler.js b/backend/middleware/asyncHandler.js new file mode 100644 index 0000000..278ecab --- /dev/null +++ b/backend/middleware/asyncHandler.js @@ -0,0 +1,8 @@ +// Async handler to wrap async route handlers and catch errors +const asyncHandler = fn => (req, res, next) => { + return Promise + .resolve(fn(req, res, next)) + .catch(next); +}; + +module.exports = asyncHandler; diff --git a/backend/middleware/auth.js b/backend/middleware/auth.js new file mode 100644 index 0000000..592c070 --- /dev/null +++ b/backend/middleware/auth.js @@ -0,0 +1,69 @@ +const jwt = require('jsonwebtoken'); +const User = require('../models/User'); +const asyncHandler = require('./asyncHandler'); + +// Protect routes middleware +const protect = asyncHandler(async (req, res, next) => { + let token; + + // Check if token exists in headers + if (req.headers.authorization?.startsWith('Bearer')) { + // Get token from header + token = req.headers.authorization.split(' ')[1]; + } + + if (!token) { + res.status(401); + throw new Error('Not authorized to access this route'); + } + + try { + // Verify token + const decoded = jwt.verify(token, process.env.JWT_SECRET); + + // Get user from token + const user = await User.findById(decoded.id).select('-password'); + + if (!user) { + res.status(401); + throw new Error('User not found'); + } + + // Check if user is active + if (user.status !== 'active') { + res.status(403); + throw new Error('Your account is not active. Please contact support.'); + } + + // Add user to request object + req.user = user; + next(); + } catch (error) { + res.status(401); + if (error.name === 'JsonWebTokenError') { + throw new Error('Invalid token'); + } else if (error.name === 'TokenExpiredError') { + throw new Error('Token expired'); + } else { + throw error; + } + } +}); + +// Grant access to specific roles +const authorize = (...roles) => { + return (req, res, next) => { + if (!req.user) { + res.status(500); + throw new Error('User not found in request. Protect middleware must be used first.'); + } + + if (!roles.includes(req.user.role)) { + res.status(403); + throw new Error(`User role ${req.user.role} is not authorized to access this route`); + } + next(); + }; +}; + +module.exports = { protect, authorize }; diff --git a/backend/middleware/errorMiddleware.js b/backend/middleware/errorMiddleware.js new file mode 100644 index 0000000..0136710 --- /dev/null +++ b/backend/middleware/errorMiddleware.js @@ -0,0 +1,87 @@ +const { + formatMongooseError, + formatJWTError, + formatMulterError, + formatMongoError, +} = require('../utils/errorHandler'); + +// Global error handling middleware +const errorHandler = (err, req, res, next) => { + // Log error for debugging + console.error('Error:', { + name: err.name, + message: err.message, + stack: err.stack, + path: req.path, + method: req.method, + body: req.body, + params: req.params, + query: req.query, + user: req.user ? req.user.id : 'Not authenticated' + }); + + // Format specific errors + let error = err; + + if (err.name === 'ValidationError') { + error = formatMongooseError(err); + } + if (err.name === 'JsonWebTokenError' || err.name === 'TokenExpiredError') { + error = formatJWTError(err); + } + if (err.name === 'MulterError') { + error = formatMulterError(err); + } + if (err.name === 'MongoError' || err.name === 'MongoServerError') { + error = formatMongoError(err); + } + + // Set status code + const statusCode = res.statusCode === 200 ? 500 : res.statusCode; + res.status(statusCode); + + // Send error response + res.json({ + success: false, + status: error.status || 'error', + message: error.message || 'Internal server error', + ...(error.validationErrors && { errors: error.validationErrors }), + stack: process.env.NODE_ENV === 'production' ? '🥞' : error.stack, + }); +}; + +// Handle 404 errors for undefined routes +const notFound = (req, res, next) => { + const error = new Error(`Not Found - ${req.originalUrl}`); + res.status(404); + next(error); +}; + +// Handle uncaught exceptions +const handleUncaughtExceptions = () => { + process.on('uncaughtException', (err) => { + console.error('UNCAUGHT EXCEPTION! 💥'); + console.error(err.name, err.message); + console.error('Stack:', err.stack); + process.exit(1); + }); +}; + +// Handle unhandled promise rejections +const handleUnhandledRejections = (server) => { + process.on('unhandledRejection', (err) => { + console.error('UNHANDLED REJECTION! 💥'); + console.error(err.name, err.message); + console.error('Stack:', err.stack); + server.close(() => { + process.exit(1); + }); + }); +}; + +module.exports = { + errorHandler, + notFound, + handleUncaughtExceptions, + handleUnhandledRejections, +}; diff --git a/backend/middleware/isSuperAdmin.js b/backend/middleware/isSuperAdmin.js new file mode 100644 index 0000000..c68cc58 --- /dev/null +++ b/backend/middleware/isSuperAdmin.js @@ -0,0 +1,20 @@ +const User = require('../models/User'); + +const isSuperAdmin = async (req, res, next) => { + try { + const user = await User.findById(req.user._id); + + if (!user || user.role !== 'superadmin') { + return res.status(403).json({ + message: 'Access denied. Super admin privileges required.' + }); + } + + next(); + } catch (error) { + console.error('Error in super admin middleware:', error); + res.status(500).json({ message: 'Internal server error' }); + } +}; + +module.exports = isSuperAdmin; diff --git a/backend/middleware/trackActivity.js b/backend/middleware/trackActivity.js new file mode 100644 index 0000000..3c87de6 --- /dev/null +++ b/backend/middleware/trackActivity.js @@ -0,0 +1,31 @@ +const User = require('../models/User'); + +const trackActivity = async (req, res, next) => { + try { + if (req.user) { + // Update user's last active timestamp + await User.findByIdAndUpdate( + req.user._id, + { + $set: { lastActive: new Date() }, + $push: { + activityLog: { + action: req.method, + path: req.path, + timestamp: new Date(), + ip: req.ip, + userAgent: req.get('user-agent') + } + } + }, + { new: true } + ); + } + next(); + } catch (error) { + console.error('Error tracking user activity:', error); + next(); // Continue even if tracking fails + } +}; + +module.exports = trackActivity; diff --git a/backend/middleware/upload.js b/backend/middleware/upload.js new file mode 100644 index 0000000..8989dc4 --- /dev/null +++ b/backend/middleware/upload.js @@ -0,0 +1,33 @@ +const multer = require('multer'); +const path = require('path'); + +// Multer config +const storage = multer.diskStorage({ + destination: function(req, file, cb) { + cb(null, 'public/uploads/properties'); + }, + filename: function(req, file, cb) { + // Create unique filename: timestamp-originalname + cb(null, `${Date.now()}-${file.originalname.replace(/\s+/g, '-')}`); + } +}); + +// File filter +const fileFilter = (req, file, cb) => { + // Allow only images + if (file.mimetype.startsWith('image')) { + cb(null, true); + } else { + cb(new Error('Not an image! Please upload only images.'), false); + } +}; + +const upload = multer({ + storage: storage, + fileFilter: fileFilter, + limits: { + fileSize: 1024 * 1024 * 5 // 5MB max file size + } +}); + +module.exports = upload; diff --git a/backend/models/Booking.js b/backend/models/Booking.js new file mode 100644 index 0000000..5d0a7c3 --- /dev/null +++ b/backend/models/Booking.js @@ -0,0 +1,158 @@ +const mongoose = require('mongoose'); + +const bookingSchema = new mongoose.Schema({ + property: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Property', + required: [true, 'Property is required'] + }, + tenant: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: [true, 'Tenant is required'] + }, + owner: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: [true, 'Owner is required'] + }, + startDate: { + type: Date, + required: [true, 'Start date is required'] + }, + endDate: { + type: Date, + required: [true, 'End date is required'] + }, + totalAmount: { + type: Number, + required: [true, 'Total amount is required'] + }, + status: { + type: String, + enum: ['pending', 'approved', 'rejected', 'cancelled', 'completed'], + default: 'pending' + }, + paymentStatus: { + type: String, + enum: ['pending', 'partial', 'completed', 'refunded'], + default: 'pending' + }, + depositAmount: { + type: Number, + required: [true, 'Deposit amount is required'] + }, + depositPaid: { + type: Boolean, + default: false + }, + documents: [{ + type: { + type: String, + enum: ['id', 'proof_of_income', 'rental_agreement', 'other'], + required: true + }, + url: { + type: String, + required: true + }, + verified: { + type: Boolean, + default: false + } + }], + messages: [{ + sender: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: true + }, + message: { + type: String, + required: true + }, + timestamp: { + type: Date, + default: Date.now + } + }], + moveInDetails: { + preferredTime: String, + specialRequests: String, + parkingRequired: Boolean, + movingCompany: { + name: String, + contact: String + } + }, + cancellation: { + date: Date, + reason: String, + requestedBy: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User' + }, + refundAmount: Number, + status: { + type: String, + enum: ['pending', 'approved', 'rejected'], + default: 'pending' + } + } +}, { + timestamps: true, + toJSON: { virtuals: true }, + toObject: { virtuals: true } +}); + +// Calculate duration in months +bookingSchema.virtual('durationMonths').get(function() { + return Math.ceil((this.endDate - this.startDate) / (1000 * 60 * 60 * 24 * 30)); +}); + +// Ensure end date is after start date +bookingSchema.pre('save', function(next) { + if (this.endDate <= this.startDate) { + next(new Error('End date must be after start date')); + } + next(); +}); + +// Index for efficient queries +bookingSchema.index({ property: 1, startDate: 1, endDate: 1 }); +bookingSchema.index({ tenant: 1, status: 1 }); +bookingSchema.index({ owner: 1, status: 1 }); + +// Check for booking conflicts +bookingSchema.statics.checkAvailability = async function(propertyId, startDate, endDate, excludeBookingId = null) { + const query = { + property: propertyId, + status: { $nin: ['rejected', 'cancelled'] }, + $or: [ + { startDate: { $lte: endDate }, endDate: { $gte: startDate } } + ] + }; + + if (excludeBookingId) { + query._id = { $ne: excludeBookingId }; + } + + const conflictingBooking = await this.findOne(query); + return !conflictingBooking; +}; + +// Calculate total amount +bookingSchema.methods.calculateTotalAmount = async function() { + const property = await mongoose.model('Property').findById(this.property); + if (!property) throw new Error('Property not found'); + + const durationMonths = this.durationMonths; + this.totalAmount = property.price * durationMonths; + this.depositAmount = property.price; // One month rent as deposit + + return this.totalAmount; +}; + +const Booking = mongoose.model('Booking', bookingSchema); + +module.exports = Booking; diff --git a/backend/models/Chatbot.js b/backend/models/Chatbot.js new file mode 100644 index 0000000..444ce7a --- /dev/null +++ b/backend/models/Chatbot.js @@ -0,0 +1,163 @@ +const mongoose = require('mongoose'); + +const chatbotSchema = new mongoose.Schema({ + userId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: false // Allow anonymous users + }, + sessionId: { + type: String, + required: true + }, + timestamp: { + type: Date, + default: Date.now, + required: true + }, + query: { + type: String, + required: true + }, + intent: { + type: String, + required: true + }, + confidence: { + type: Number, + required: true, + min: 0, + max: 1 + }, + successful: { + type: Boolean, + required: true, + default: false + }, + handedOffToHuman: { + type: Boolean, + required: true, + default: false + }, + responseTime: { + type: Number, // in milliseconds + required: true + }, + response: { + type: String, + required: true + }, + feedback: { + rating: { + type: Number, + enum: [1, 2, 3, 4, 5], + required: false + }, + comment: { + type: String, + required: false + }, + timestamp: { + type: Date, + required: false + } + }, + context: { + type: Map, + of: mongoose.Schema.Types.Mixed, + required: false + }, + metadata: { + browser: String, + os: String, + device: String, + location: { + country: String, + city: String + } + } +}, { + timestamps: true, + toJSON: { virtuals: true }, + toObject: { virtuals: true } +}); + +// Indexes for better query performance +chatbotSchema.index({ timestamp: -1 }); +chatbotSchema.index({ userId: 1, timestamp: -1 }); +chatbotSchema.index({ sessionId: 1, timestamp: -1 }); +chatbotSchema.index({ intent: 1 }); +chatbotSchema.index({ successful: 1 }); +chatbotSchema.index({ handedOffToHuman: 1 }); +chatbotSchema.index({ 'feedback.rating': 1 }); + +// Virtual for calculating response success rate +chatbotSchema.virtual('successRate').get(function() { + return this.successful ? 1 : 0; +}); + +// Static method to get analytics for a time range +chatbotSchema.statics.getAnalytics = async function(startDate, endDate) { + const analytics = await this.aggregate([ + { + $match: { + timestamp: { $gte: startDate, $lte: endDate } + } + }, + { + $group: { + _id: null, + totalInteractions: { $sum: 1 }, + successfulInteractions: { $sum: { $cond: ['$successful', 1, 0] } }, + averageConfidence: { $avg: '$confidence' }, + averageResponseTime: { $avg: '$responseTime' }, + humanHandoffs: { $sum: { $cond: ['$handedOffToHuman', 1, 0] } }, + feedbackCount: { + $sum: { $cond: [{ $ifNull: ['$feedback.rating', false] }, 1, 0] } + }, + averageFeedback: { $avg: '$feedback.rating' } + } + }, + { + $project: { + _id: 0, + totalInteractions: 1, + successfulInteractions: 1, + successRate: { + $divide: ['$successfulInteractions', '$totalInteractions'] + }, + averageConfidence: 1, + averageResponseTime: 1, + humanHandoffs: 1, + feedbackStats: { + count: '$feedbackCount', + average: '$averageFeedback' + } + } + } + ]); + + return analytics[0] || null; +}; + +// Method to add feedback to an interaction +chatbotSchema.methods.addFeedback = async function(rating, comment) { + this.feedback = { + rating, + comment, + timestamp: new Date() + }; + return this.save(); +}; + +// Middleware to ensure required fields +chatbotSchema.pre('save', function(next) { + if (!this.sessionId) { + this.sessionId = mongoose.Types.ObjectId().toString(); + } + next(); +}); + +const Chatbot = mongoose.model('Chatbot', chatbotSchema); + +module.exports = Chatbot; diff --git a/backend/models/ChatbotInteraction.js b/backend/models/ChatbotInteraction.js new file mode 100644 index 0000000..278d109 --- /dev/null +++ b/backend/models/ChatbotInteraction.js @@ -0,0 +1,171 @@ +const mongoose = require('mongoose'); + +const chatbotInteractionSchema = new mongoose.Schema({ + userId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: true, + }, + userMessage: { + type: String, + required: true, + }, + botResponse: { + type: String, + required: true, + }, + intent: { + type: String, + required: true, + }, + confidence: { + type: Number, + required: true, + min: 0, + max: 1, + }, + parameters: { + type: Map, + of: mongoose.Schema.Types.Mixed, + }, + contexts: [{ + name: String, + parameters: Map, + lifespanCount: Number, + }], + successful: { + type: Boolean, + required: true, + }, + handedOffToHuman: { + type: Boolean, + default: false, + }, + humanResponse: { + agentId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + }, + message: String, + timestamp: Date, + }, + feedback: { + rating: { + type: Number, + min: 1, + max: 5, + }, + comment: String, + timestamp: Date, + }, + metadata: { + platform: String, + userAgent: String, + language: String, + }, + timestamp: { + type: Date, + required: true, + default: Date.now, + } +}, { + timestamps: true, +}); + +// Indexes for analytics queries +chatbotInteractionSchema.index({ intent: 1, timestamp: -1 }); +chatbotInteractionSchema.index({ successful: 1, timestamp: -1 }); +chatbotInteractionSchema.index({ confidence: 1 }); +chatbotInteractionSchema.index({ 'feedback.rating': 1 }); + +// Virtual for response time if handed off to human +chatbotInteractionSchema.virtual('humanResponseTime').get(function() { + if (this.handedOffToHuman && this.humanResponse) { + return this.humanResponse.timestamp - this.timestamp; + } + return null; +}); + +// Methods +chatbotInteractionSchema.methods.addFeedback = async function(rating, comment) { + this.feedback = { + rating, + comment, + timestamp: new Date(), + }; + return this.save(); +}; + +chatbotInteractionSchema.methods.handoffToHuman = async function(agentId) { + this.handedOffToHuman = true; + if (agentId) { + this.humanResponse = { + agentId, + timestamp: new Date(), + }; + } + return this.save(); +}; + +// Statics +chatbotInteractionSchema.statics.getIntentPerformance = async function(intent, timeRange = '7d') { + const startDate = new Date(); + startDate.setDate(startDate.getDate() - parseInt(timeRange)); + + return this.aggregate([ + { + $match: { + intent, + timestamp: { $gte: startDate }, + }, + }, + { + $group: { + _id: null, + totalInteractions: { $sum: 1 }, + successfulInteractions: { + $sum: { $cond: ['$successful', 1, 0] }, + }, + averageConfidence: { $avg: '$confidence' }, + handoffs: { + $sum: { $cond: ['$handedOffToHuman', 1, 0] }, + }, + averageRating: { $avg: '$feedback.rating' }, + }, + }, + ]); +}; + +chatbotInteractionSchema.statics.getFeedbackSummary = async function(timeRange = '7d') { + const startDate = new Date(); + startDate.setDate(startDate.getDate() - parseInt(timeRange)); + + return this.aggregate([ + { + $match: { + timestamp: { $gte: startDate }, + 'feedback.rating': { $exists: true }, + }, + }, + { + $group: { + _id: '$intent', + averageRating: { $avg: '$feedback.rating' }, + totalFeedback: { $sum: 1 }, + ratings: { + $push: { + rating: '$feedback.rating', + comment: '$feedback.comment', + }, + }, + }, + }, + { + $sort: { averageRating: -1 }, + }, + ]); +}; + +const ChatbotInteraction = mongoose.model('ChatbotInteraction', chatbotInteractionSchema); + +module.exports = ChatbotInteraction; diff --git a/backend/models/Message.js b/backend/models/Message.js new file mode 100644 index 0000000..8a05547 --- /dev/null +++ b/backend/models/Message.js @@ -0,0 +1,252 @@ +const mongoose = require('mongoose'); + +const reactionSchema = new mongoose.Schema({ + user: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: true, + }, + emoji: { + type: String, + required: true, + }, + createdAt: { + type: Date, + default: Date.now, + }, +}); + +const attachmentSchema = new mongoose.Schema({ + type: { + type: String, + enum: ['image', 'file', 'voice', 'link'], + required: true, + }, + url: { + type: String, + required: true, + }, + filename: String, + filesize: Number, + mimeType: String, + duration: Number, // For voice messages + thumbnail: String, // For images + metadata: { + title: String, // For link previews + description: String, + image: String, + siteName: String, + }, +}); + +const messageSchema = new mongoose.Schema({ + content: { + type: String, + required: function() { + // Content is required unless it's a voice message or file + return !this.attachments || this.attachments.length === 0; + }, + }, + sender: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: true, + }, + recipient: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: true, + }, + propertyId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Property', + required: true, + }, + chatId: { + type: String, // Composite key of sorted user IDs for direct chats or group ID + required: true, + index: true, + }, + type: { + type: String, + enum: ['text', 'system', 'notification'], + default: 'text', + }, + formatting: { + bold: [{ start: Number, end: Number }], + italic: [{ start: Number, end: Number }], + code: [{ start: Number, end: Number }], + link: [{ + start: Number, + end: Number, + url: String, + }], + }, + attachments: [attachmentSchema], + reactions: [reactionSchema], + replyTo: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Message', + }, + forwardedFrom: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Message', + }, + editHistory: [{ + content: String, + editedAt: { + type: Date, + default: Date.now, + }, + }], + deliveryStatus: { + type: String, + enum: ['sent', 'delivered', 'read'], + default: 'sent', + }, + read: { + type: Boolean, + default: false, + }, + readBy: [{ + user: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + }, + readAt: { + type: Date, + default: Date.now, + }, + }], + readAt: Date, + expiresAt: Date, + isBookmarked: { + type: Boolean, + default: false, + }, + isReported: { + type: Boolean, + default: false, + }, + reports: [{ + user: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + }, + reason: String, + createdAt: { + type: Date, + default: Date.now, + }, + }], + isEdited: { + type: Boolean, + default: false, + }, + isDeleted: { + type: Boolean, + default: false, + }, + deletedAt: Date, + createdAt: { + type: Date, + default: Date.now, + }, + metadata: { + clientId: String, // For message syncing + deviceInfo: { + type: String, + platform: String, + }, + }, +}); + +// Indexes for efficient queries +messageSchema.index({ sender: 1, recipient: 1 }); +messageSchema.index({ propertyId: 1 }); +messageSchema.index({ chatId: 1, createdAt: -1 }); +messageSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 }); // TTL index +messageSchema.index({ 'reactions.user': 1 }); +messageSchema.index({ isBookmarked: 1 }); +messageSchema.index({ isReported: 1 }); +messageSchema.index({ 'readBy.user': 1 }); + +// Virtual for reaction counts +messageSchema.virtual('reactionCounts').get(function() { + const counts = {}; + this.reactions.forEach(reaction => { + counts[reaction.emoji] = (counts[reaction.emoji] || 0) + 1; + }); + return counts; +}); + +// Method to check if message is expired +messageSchema.methods.isExpired = function() { + if (!this.expiresAt) return false; + return new Date() >= this.expiresAt; +}; + +// Method to add reaction +messageSchema.methods.addReaction = async function(userId, emoji) { + const existingReaction = this.reactions.find( + r => r.user.toString() === userId.toString() + ); + + if (existingReaction) { + existingReaction.emoji = emoji; + existingReaction.createdAt = new Date(); + } else { + this.reactions.push({ user: userId, emoji }); + } + + return this.save(); +}; + +// Method to remove reaction +messageSchema.methods.removeReaction = async function(userId) { + this.reactions = this.reactions.filter( + r => r.user.toString() !== userId.toString() + ); + return this.save(); +}; + +// Method to mark as read +messageSchema.methods.markAsRead = async function(userId) { + if (!this.readBy.some(r => r.user.toString() === userId.toString())) { + this.readBy.push({ user: userId }); + this.read = true; + this.readAt = new Date(); + this.deliveryStatus = 'read'; + return this.save(); + } + return this; +}; + +// Method to edit message +messageSchema.methods.edit = async function(newContent) { + if (this.content !== newContent) { + this.editHistory.push({ content: this.content }); + this.content = newContent; + this.isEdited = true; + return this.save(); + } + return this; +}; + +// Pre-save middleware +messageSchema.pre('save', function(next) { + if (this.isExpired()) { + next(new Error('Cannot save expired message')); + } else { + // Generate chatId if not exists + if (!this.chatId) { + const participants = [this.sender, this.recipient].sort(); + this.chatId = participants.join('_'); + } + next(); + } +}); + +const Message = mongoose.model('Message', messageSchema); + +module.exports = Message; diff --git a/backend/models/Notification.js b/backend/models/Notification.js new file mode 100644 index 0000000..9190cc0 --- /dev/null +++ b/backend/models/Notification.js @@ -0,0 +1,145 @@ +const mongoose = require('mongoose'); + +const notificationSchema = new mongoose.Schema({ + user: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: true + }, + sender: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User' + }, + type: { + type: String, + enum: ['success', 'error', 'warning', 'info'], + default: 'info' + }, + category: { + type: String, + enum: ['system', 'booking', 'property', 'message', 'payment', 'marketing'], + required: true + }, + title: { + type: String, + required: true + }, + message: { + type: String, + required: true + }, + isRead: { + type: Boolean, + default: false + }, + actionUrl: { + type: String + }, + metadata: { + type: Map, + of: mongoose.Schema.Types.Mixed + }, + expiresAt: { + type: Date + }, + priority: { + type: String, + enum: ['low', 'medium', 'high', 'urgent'], + default: 'medium' + } +}, { + timestamps: true, + toJSON: { virtuals: true }, + toObject: { virtuals: true } +}); + +// Indexes for better query performance +notificationSchema.index({ user: 1, createdAt: -1 }); +notificationSchema.index({ user: 1, isRead: 1 }); +notificationSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 }); + +// Virtual for time elapsed since notification +notificationSchema.virtual('timeElapsed').get(function() { + return Date.now() - this.createdAt.getTime(); +}); + +// Static method to get unread count for a user +notificationSchema.statics.getUnreadCount = async function(userId) { + return this.countDocuments({ user: userId, isRead: false }); +}; + +// Static method to mark all as read for a user +notificationSchema.statics.markAllAsRead = async function(userId) { + return this.updateMany( + { user: userId, isRead: false }, + { $set: { isRead: true } } + ); +}; + +// Static method to create a new notification +notificationSchema.statics.createNotification = async function(data) { + const notification = new this(data); + + // Get user preferences + const user = await mongoose.model('User').findById(data.user) + .select('preferences.notifications'); + + // Check if user wants this type of notification + if (user?.preferences?.notifications?.[data.category] === false) { + return null; + } + + await notification.save(); + return notification; +}; + +// Static method to get notifications with pagination +notificationSchema.statics.getNotifications = async function(userId, page = 1, limit = 20) { + const skip = (page - 1) * limit; + + const [notifications, total] = await Promise.all([ + this.find({ user: userId }) + .sort({ createdAt: -1 }) + .skip(skip) + .limit(limit) + .populate('sender', 'name email') + .lean(), + this.countDocuments({ user: userId }) + ]); + + return { + notifications, + pagination: { + page, + limit, + total, + pages: Math.ceil(total / limit) + } + }; +}; + +// Pre-save middleware to set expiration +notificationSchema.pre('save', function(next) { + if (!this.expiresAt) { + // Set default expiration to 30 days + this.expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); + } + next(); +}); + +// Pre-save middleware to validate preferences +notificationSchema.pre('save', async function(next) { + if (this.isNew) { + const user = await mongoose.model('User').findById(this.user) + .select('preferences.notifications'); + + if (user?.preferences?.notifications?.[this.category] === false) { + const error = new Error('User has disabled this type of notification'); + error.name = 'ValidationError'; + next(error); + } + } + next(); +}); + +module.exports = mongoose.model('Notification', notificationSchema); diff --git a/backend/models/Post.js b/backend/models/Post.js new file mode 100644 index 0000000..add2af7 --- /dev/null +++ b/backend/models/Post.js @@ -0,0 +1,57 @@ +const mongoose = require('mongoose'); + +const postSchema = new mongoose.Schema({ + title: { + type: String, + required: [true, 'Please add a title'], + trim: true, + maxlength: [100, 'Title cannot be more than 100 characters'] + }, + description: { + type: String, + required: [true, 'Please add a description'], + maxlength: [2000, 'Description cannot be more than 2000 characters'] + }, + location: { + type: String, + required: [true, 'Please add a location'] + }, + price: { + type: Number, + required: [true, 'Please add a price'], + min: [0, 'Price cannot be negative'] + }, + ownerId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: [true, 'Post must have an owner'] + }, + approved: { + type: Boolean, + default: false + }, + createdAt: { + type: Date, + default: Date.now + } +}, { + timestamps: true, + toJSON: { virtuals: true }, + toObject: { virtuals: true } +}); + +// Index for faster queries +postSchema.index({ ownerId: 1, createdAt: -1 }); +postSchema.index({ approved: 1, createdAt: -1 }); +postSchema.index({ location: 'text', title: 'text', description: 'text' }); + +// Populate owner details when querying posts +postSchema.pre(/^find/, function(next) { + this.populate({ + path: 'ownerId', + select: 'name email' + }); + next(); +}); + +module.exports = mongoose.model('Post', postSchema); diff --git a/backend/models/Property.js b/backend/models/Property.js new file mode 100644 index 0000000..4496a98 --- /dev/null +++ b/backend/models/Property.js @@ -0,0 +1,356 @@ +const mongoose = require('mongoose'); + +const propertySchema = new mongoose.Schema({ + title: { + type: String, + required: [true, 'Please add a title'], + trim: true, + maxlength: [100, 'Title cannot be more than 100 characters'], + index: true + }, + description: { + type: String, + required: [true, 'Please add a description'], + maxlength: [2000, 'Description cannot be more than 2000 characters'] + }, + address: { + street: { + type: String, + required: [true, 'Please add a street address'] + }, + houseNumber: { + type: String, + required: [true, 'Please add a house number'] + }, + floor: { + type: Number, + required: [true, 'Please specify the floor number'], + min: [0, 'Floor number cannot be negative'] + }, + division: { + type: String, + required: [true, 'Please add a division'], + enum: { + values: [ + 'Dhaka', + 'Chittagong', + 'Rajshahi', + 'Khulna', + 'Barishal', + 'Sylhet', + 'Rangpur', + 'Mymensingh' + ], + message: '{VALUE} is not a valid division' + }, + index: true + }, + district: { + type: String, + required: [true, 'Please add a district'], + index: true + }, + thana: { + type: String, + required: [true, 'Please add a thana/upazila'], + index: true + }, + area: { + type: String, + required: [true, 'Please add an area'] + }, + postCode: { + type: String, + required: [true, 'Please add a postal code'], + validate: { + validator: function(v) { + return /^\d{4}$/.test(v); + }, + message: props => `${props.value} is not a valid Bangladesh postal code!` + } + }, + country: { + type: String, + default: 'Bangladesh', + required: true + } + }, + location: { + type: { + type: String, + enum: ['Point'], + required: true, + default: 'Point' + }, + coordinates: { + type: [Number], + required: true, + index: '2dsphere', + validate: { + validator: function(v) { + return v.length === 2 && + v[0] >= -180 && v[0] <= 180 && + v[1] >= -90 && v[1] <= 90; + }, + message: props => 'Invalid coordinates!' + } + } + }, + price: { + amount: { + type: Number, + required: [true, 'Please add a price'], + min: [0, 'Price cannot be negative'], + index: true + }, + currency: { + type: String, + default: 'BDT', + enum: ['BDT'] + }, + negotiable: { + type: Boolean, + default: false + }, + advancePayment: { + type: Number, + required: [true, 'Please specify advance payment amount'], + min: [0, 'Advance payment cannot be negative'] + } + }, + propertyType: { + type: String, + required: [true, 'Please add a property type'], + enum: { + values: [ + 'Apartment', + 'House', + 'Duplex', + 'Studio', + 'Villa', + 'Office', + 'Shop', + 'Warehouse', + 'Bachelor', + 'Family', + 'Sublet', + 'Mess', + 'Hostel' + ], + message: '{VALUE} is not supported' + }, + index: true + }, + status: { + type: String, + enum: { + values: ['available', 'rented', 'maintenance', 'inactive'], + message: '{VALUE} is not supported' + }, + default: 'available', + index: true + }, + features: { + size: { + value: { + type: Number, + required: [true, 'Please specify the size'], + min: [0, 'Size cannot be negative'] + }, + unit: { + type: String, + enum: ['sqft', 'katha', 'bigha'], + default: 'sqft' + } + }, + bedrooms: { + type: Number, + required: [true, 'Please specify number of bedrooms'], + min: [0, 'Number of bedrooms cannot be negative'] + }, + bathrooms: { + type: Number, + required: [true, 'Please specify number of bathrooms'], + min: [0, 'Number of bathrooms cannot be negative'] + }, + balconies: { + type: Number, + default: 0, + min: [0, 'Number of balconies cannot be negative'] + }, + parking: { + available: { + type: Boolean, + default: false + }, + type: { + type: String, + enum: ['car', 'bike', 'both', 'none'], + default: 'none' + } + }, + furnished: { + type: String, + enum: ['unfurnished', 'semi-furnished', 'fully-furnished'], + default: 'unfurnished' + }, + utilities: { + electricity: { + type: Boolean, + default: true + }, + gas: { + type: Boolean, + default: false + }, + water: { + type: Boolean, + default: true + }, + internet: { + type: Boolean, + default: false + }, + maintenance: { + type: Boolean, + default: false + } + }, + amenities: [{ + type: String, + enum: [ + 'lift', + 'generator', + 'security', + 'cctv', + 'intercom', + 'prayer_room', + 'community_hall', + 'roof_access', + 'gym', + 'playground' + ] + }] + }, + preferences: { + tenantType: [{ + type: String, + enum: ['family', 'bachelor', 'office', 'student', 'any'], + default: ['any'] + }], + gender: { + type: String, + enum: ['male', 'female', 'any'], + default: 'any' + }, + maxOccupants: { + type: Number, + min: [1, 'Maximum occupants must be at least 1'] + }, + petsAllowed: { + type: Boolean, + default: false + } + }, + images: [{ + url: { + type: String, + required: true, + validate: { + validator: function(v) { + return /^https?:\/\/.+\.(jpg|jpeg|png|webp)(\?.*)?$/i.test(v); + }, + message: props => `${props.value} is not a valid image URL!` + } + }, + alt: { + type: String, + default: 'Property image' + } + }], + virtualTour: { + type: String, + validate: { + validator: function(v) { + return !v || /^https?:\/\/.+$/i.test(v); + }, + message: props => `${props.value} is not a valid URL!` + } + }, + owner: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: true, + index: true + }, + rating: { + type: Number, + min: 0, + max: 5, + default: 0 + }, + reviews: [{ + user: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User' + }, + rating: { + type: Number, + required: true, + min: 1, + max: 5 + }, + comment: { + type: String, + required: true, + maxlength: 500 + }, + createdAt: { + type: Date, + default: Date.now + } + }] +}, { + timestamps: true, + toJSON: { virtuals: true }, + toObject: { virtuals: true } +}); + +// Compound index for location-based searches +propertySchema.index({ 'location': '2dsphere', 'status': 1, 'price': 1 }); + +// Virtual for average rating +propertySchema.virtual('averageRating').get(function() { + if (this.reviews.length === 0) return 0; + const sum = this.reviews.reduce((total, review) => total + review.rating, 0); + return Math.round((sum / this.reviews.length) * 10) / 10; +}); + +// Method to check if property is available for a date range +propertySchema.methods.isAvailable = async function(startDate, endDate) { + const Booking = mongoose.model('Booking'); + const conflictingBookings = await Booking.find({ + property: this._id, + status: { $in: ['confirmed', 'pending'] }, + $or: [ + { + startDate: { $lte: endDate }, + endDate: { $gte: startDate } + } + ] + }); + return conflictingBookings.length === 0; +}; + +// Add text index for search +propertySchema.index({ + title: 'text', + description: 'text', + 'address.street': 'text', + 'address.city': 'text', + 'address.area': 'text' +}); + +const Property = mongoose.model('Property', propertySchema); + +module.exports = Property; diff --git a/backend/models/PropertyAnalytics.js b/backend/models/PropertyAnalytics.js new file mode 100644 index 0000000..9521fa0 --- /dev/null +++ b/backend/models/PropertyAnalytics.js @@ -0,0 +1,200 @@ +const mongoose = require('mongoose'); + +const propertyAnalyticsSchema = new mongoose.Schema({ + propertyId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Property', + required: true + }, + views: { + total: { type: Number, default: 0 }, + unique: { type: Number, default: 0 }, + byDate: [{ + date: Date, + count: Number, + uniqueCount: Number + }] + }, + inquiries: { + total: { type: Number, default: 0 }, + converted: { type: Number, default: 0 }, + bySource: [{ + source: String, + count: Number, + conversionRate: Number + }] + }, + searchAppearances: { + total: { type: Number, default: 0 }, + byKeyword: [{ + keyword: String, + count: Number, + position: Number + }] + }, + engagement: { + averageTimeOnPage: Number, + bounceRate: Number, + favoriteCount: { type: Number, default: 0 }, + shareCount: { type: Number, default: 0 } + }, + pricing: { + priceHistory: [{ + price: Number, + date: Date, + reason: String + }], + marketComparison: { + averageAreaPrice: Number, + percentDifference: Number, + lastUpdated: Date + } + }, + availability: { + totalDaysListed: { type: Number, default: 0 }, + occupancyRate: Number, + seasonalDemand: [{ + season: String, + demandScore: Number, + averageInquiries: Number + }] + }, + performance: { + score: { type: Number, min: 0, max: 100 }, + factors: [{ + name: String, + impact: Number, + recommendation: String + }] + } +}, { + timestamps: true, + toJSON: { virtuals: true }, + toObject: { virtuals: true } +}); + +// Indexes for better query performance +propertyAnalyticsSchema.index({ propertyId: 1 }); +propertyAnalyticsSchema.index({ 'views.total': -1 }); +propertyAnalyticsSchema.index({ 'performance.score': -1 }); +propertyAnalyticsSchema.index({ 'inquiries.total': -1 }); + +// Virtual for calculating conversion rate +propertyAnalyticsSchema.virtual('conversionRate').get(function() { + return this.inquiries.total > 0 + ? (this.inquiries.converted / this.inquiries.total) * 100 + : 0; +}); + +// Method to update views +propertyAnalyticsSchema.methods.trackView = async function(userId, isUnique = false) { + this.views.total += 1; + if (isUnique) { + this.views.unique += 1; + } + + const today = new Date(); + today.setHours(0, 0, 0, 0); + + const dayStats = this.views.byDate.find( + stat => stat.date.getTime() === today.getTime() + ); + + if (dayStats) { + dayStats.count += 1; + if (isUnique) dayStats.uniqueCount += 1; + } else { + this.views.byDate.push({ + date: today, + count: 1, + uniqueCount: isUnique ? 1 : 0 + }); + } + + return this.save(); +}; + +// Method to calculate and update performance score +propertyAnalyticsSchema.methods.updatePerformanceScore = async function() { + const weights = { + views: 0.3, + inquiries: 0.25, + engagement: 0.2, + availability: 0.15, + pricing: 0.1 + }; + + const viewsScore = Math.min((this.views.total / 100) * 100, 100); + const inquiriesScore = Math.min((this.inquiries.total / 20) * 100, 100); + const engagementScore = Math.min( + ((this.engagement.favoriteCount + this.engagement.shareCount) / 50) * 100, + 100 + ); + + this.performance.score = + (viewsScore * weights.views) + + (inquiriesScore * weights.inquiries) + + (engagementScore * weights.engagement); + + // Add performance factors and recommendations + this.performance.factors = []; + + if (viewsScore < 50) { + this.performance.factors.push({ + name: 'Low Visibility', + impact: -10, + recommendation: 'Consider improving property photos and description' + }); + } + + if (this.inquiries.total > 0 && this.conversionRate < 20) { + this.performance.factors.push({ + name: 'Low Conversion Rate', + impact: -15, + recommendation: 'Review pricing strategy and property amenities' + }); + } + + return this.save(); +}; + +// Static method to get top performing properties +propertyAnalyticsSchema.statics.getTopPerforming = async function(limit = 10) { + return this.find() + .sort({ 'performance.score': -1 }) + .limit(limit) + .populate('propertyId'); +}; + +// Static method to get market insights +propertyAnalyticsSchema.statics.getMarketInsights = async function(area) { + return this.aggregate([ + { + $lookup: { + from: 'properties', + localField: 'propertyId', + foreignField: '_id', + as: 'property' + } + }, + { + $match: { + 'property.area': area + } + }, + { + $group: { + _id: null, + averagePrice: { $avg: '$pricing.marketComparison.averageAreaPrice' }, + totalProperties: { $sum: 1 }, + averageViewsPerProperty: { $avg: '$views.total' }, + averageInquiriesPerProperty: { $avg: '$inquiries.total' }, + averageOccupancyRate: { $avg: '$availability.occupancyRate' } + } + } + ]); +}; + +const PropertyAnalytics = mongoose.model('PropertyAnalytics', propertyAnalyticsSchema); + +module.exports = PropertyAnalytics; diff --git a/backend/models/Report.js b/backend/models/Report.js new file mode 100644 index 0000000..88e19cb --- /dev/null +++ b/backend/models/Report.js @@ -0,0 +1,51 @@ +const mongoose = require('mongoose'); + +const reportSchema = new mongoose.Schema({ + type: { + type: String, + enum: ['post', 'user'], + required: true + }, + targetId: { + type: mongoose.Schema.Types.ObjectId, + refPath: 'type', + required: true + }, + reportedBy: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: true + }, + reason: { + type: String, + required: true + }, + description: { + type: String, + required: true + }, + status: { + type: String, + enum: ['pending', 'reviewed', 'dismissed'], + default: 'pending' + }, + reviewedBy: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User' + }, + reviewNotes: String, + createdAt: { + type: Date, + default: Date.now + }, + reviewedAt: Date +}); + +// Index for efficient queries +reportSchema.index({ type: 1, targetId: 1 }); +reportSchema.index({ status: 1 }); +reportSchema.index({ createdAt: -1 }); + +const Report = mongoose.model('Report', reportSchema); + +module.exports = Report; diff --git a/backend/models/Review.js b/backend/models/Review.js new file mode 100644 index 0000000..3eddfd7 --- /dev/null +++ b/backend/models/Review.js @@ -0,0 +1,94 @@ +const mongoose = require('mongoose'); + +const reviewSchema = new mongoose.Schema({ + propertyId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Property', + required: [true, 'Property ID is required'] + }, + userId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: [true, 'User ID is required'] + }, + rating: { + type: Number, + required: [true, 'Please add a rating'], + min: [1, 'Rating must be at least 1'], + max: [5, 'Rating cannot be more than 5'] + }, + comment: { + type: String, + required: [true, 'Please add a comment'], + trim: true, + maxlength: [500, 'Comment cannot be more than 500 characters'] + }, + images: [{ + type: String, + validate: { + validator: function(v) { + // Basic URL validation + return /^https?:\/\/.*\.(png|jpg|jpeg|gif)$/i.test(v); + }, + message: props => `${props.value} is not a valid image URL!` + } + }], + status: { + type: String, + enum: ['pending', 'approved', 'rejected'], + default: 'pending' + }, + createdAt: { + type: Date, + default: Date.now + } +}, { + timestamps: true, + toJSON: { virtuals: true }, + toObject: { virtuals: true } +}); + +// Prevent user from submitting more than one review per property +reviewSchema.index({ propertyId: 1, userId: 1 }, { unique: true }); + +// Static method to calculate average rating +reviewSchema.statics.getAverageRating = async function(propertyId) { + const stats = await this.aggregate([ + { + $match: { propertyId: new mongoose.Types.ObjectId(propertyId), status: 'approved' } + }, + { + $group: { + _id: '$propertyId', + averageRating: { $avg: '$rating' }, + numReviews: { $sum: 1 } + } + } + ]); + + if (stats.length > 0) { + await mongoose.model('Property').findByIdAndUpdate(propertyId, { + averageRating: Math.round(stats[0].averageRating * 10) / 10, + numReviews: stats[0].numReviews + }); + } else { + await mongoose.model('Property').findByIdAndUpdate(propertyId, { + averageRating: 0, + numReviews: 0 + }); + } +}; + +// Call getAverageRating after save +reviewSchema.post('save', function() { + this.constructor.getAverageRating(this.propertyId); +}); + +// Call getAverageRating before remove +reviewSchema.pre('remove', function() { + this.constructor.getAverageRating(this.propertyId); +}); + +const Review = mongoose.model('Review', reviewSchema); + +module.exports = Review; diff --git a/backend/models/Settings.js b/backend/models/Settings.js new file mode 100644 index 0000000..0aa5198 --- /dev/null +++ b/backend/models/Settings.js @@ -0,0 +1,151 @@ +const mongoose = require('mongoose'); + +const settingsSchema = new mongoose.Schema({ + postApprovalRequired: { + type: Boolean, + default: true, + description: 'Require admin approval for new property listings' + }, + maxImagesPerPost: { + type: Number, + default: 10, + description: 'Maximum number of images allowed per property listing' + }, + maxFileSize: { + type: Number, + default: 5 * 1024 * 1024, // 5MB + description: 'Maximum file size for uploads in bytes' + }, + allowedFileTypes: { + type: [String], + default: ['image/jpeg', 'image/png', 'image/webp', 'application/pdf'], + description: 'Allowed file types for uploads' + }, + userVerificationRequired: { + type: Boolean, + default: true, + description: 'Require email verification for new users' + }, + maintenanceMode: { + type: Boolean, + default: false, + description: 'Put the site in maintenance mode' + }, + contactEmail: { + type: String, + required: true, + default: 'support@houserental.com', + description: 'Contact email for support' + }, + reportThreshold: { + type: Number, + default: 5, + description: 'Number of reports before content is automatically hidden' + }, + userRoles: { + type: Map, + of: { + permissions: [String], + description: String + }, + default: { + 'user': { + permissions: ['create_post', 'send_message'], + description: 'Regular user' + }, + 'admin': { + permissions: ['manage_posts', 'manage_users', 'view_reports'], + description: 'Administrator' + }, + 'superadmin': { + permissions: ['manage_settings', 'manage_admins', 'all'], + description: 'Super Administrator' + } + } + }, + chatSettings: { + maxFileSize: { + type: Number, + default: 10 * 1024 * 1024, // 10MB + description: 'Maximum file size for chat attachments' + }, + messageRetentionDays: { + type: Number, + default: 30, + description: 'Number of days to retain chat messages' + }, + allowVoiceMessages: { + type: Boolean, + default: true, + description: 'Allow voice messages in chat' + } + }, + analytics: { + googleAnalyticsId: { + type: String, + default: '', + description: 'Google Analytics tracking ID' + }, + enableErrorTracking: { + type: Boolean, + default: true, + description: 'Enable error tracking and reporting' + } + }, + seo: { + title: { + type: String, + default: 'House Rental Platform', + description: 'Default site title' + }, + description: { + type: String, + default: 'Find your perfect rental property', + description: 'Default site description' + }, + keywords: { + type: [String], + default: ['house', 'rental', 'property', 'real estate'], + description: 'Default SEO keywords' + } + } +}, { + timestamps: true, + collection: 'settings' +}); + +// Ensure only one settings document exists +settingsSchema.statics.getInstance = async function() { + let settings = await this.findOne(); + if (!settings) { + settings = await this.create({}); + } + return settings; +}; + +// Validate settings before save +settingsSchema.pre('save', function(next) { + // Ensure reportThreshold is positive + if (this.reportThreshold < 1) { + this.reportThreshold = 1; + } + + // Ensure maxImagesPerPost is within reasonable limits + if (this.maxImagesPerPost < 1) { + this.maxImagesPerPost = 1; + } else if (this.maxImagesPerPost > 50) { + this.maxImagesPerPost = 50; + } + + // Ensure maxFileSize is within limits + const maxAllowedSize = 50 * 1024 * 1024; // 50MB + if (this.maxFileSize > maxAllowedSize) { + this.maxFileSize = maxAllowedSize; + } + + next(); +}); + +const Settings = mongoose.model('Settings', settingsSchema); + +module.exports = Settings; diff --git a/backend/models/User.js b/backend/models/User.js new file mode 100644 index 0000000..e67382a --- /dev/null +++ b/backend/models/User.js @@ -0,0 +1,299 @@ +const mongoose = require('mongoose'); +const bcrypt = require('bcryptjs'); + +const activityLogSchema = new mongoose.Schema({ + action: { + type: String, + required: true + }, + path: { + type: String, + required: true + }, + timestamp: { + type: Date, + default: Date.now + }, + ip: String, + userAgent: String +}, { _id: false }); + +const securityLogSchema = new mongoose.Schema({ + eventType: { + type: String, + required: true, + enum: ['login', 'logout', 'password_change', 'settings_change', 'failed_login', '2fa_enabled', '2fa_disabled'] + }, + timestamp: { + type: Date, + default: Date.now + }, + device: String, + location: String, + ip: String, + details: mongoose.Schema.Types.Mixed +}); + +const userSchema = new mongoose.Schema({ + firstName: { + type: String, + required: [true, 'First name is required'], + trim: true, + minlength: [2, 'First name must be at least 2 characters long'], + maxlength: [50, 'First name cannot exceed 50 characters'] + }, + lastName: { + type: String, + required: [true, 'Last name is required'], + trim: true, + minlength: [2, 'Last name must be at least 2 characters long'], + maxlength: [50, 'Last name cannot exceed 50 characters'] + }, + email: { + type: String, + required: [true, 'Email is required'], + unique: true, + trim: true, + lowercase: true, + match: [/^\w+([.-]?\w+)*@\w+([.-]?\w+)*(\.\w{2,3})+$/, 'Please enter a valid email'] + }, + password: { + type: String, + required: [true, 'Password is required'], + minlength: [8, 'Password must be at least 8 characters long'], + select: false + }, + phone: { + type: String, + required: [true, 'Phone number is required'], + match: [/^(?:\+?88)?01[3-9]\d{8}$/, 'Please enter a valid Bangladesh phone number'] + }, + avatar: { + type: String, + default: null + }, + nid: { + type: String, + required: [true, 'National ID is required'], + unique: true, + trim: true + }, + address: { + type: String, + required: [true, 'Address is required'], + trim: true + }, + role: { + type: String, + enum: { + values: ['user', 'renter', 'admin', 'super-admin'], + message: '{VALUE} is not a valid role' + }, + default: 'user' + }, + status: { + type: String, + enum: ['active', 'inactive', 'suspended', 'banned'], + default: 'active' + }, + isVerified: { + type: Boolean, + default: false + }, + security: { + twoFactorEnabled: { + type: Boolean, + default: false + }, + twoFactorSecret: { + type: String, + select: false + }, + backupCodes: { + type: [String], + select: false + }, + loginNotifications: { + type: Boolean, + default: true + }, + activityAlerts: { + type: Boolean, + default: true + }, + lastPasswordChange: { + type: Date, + default: Date.now + }, + passwordHistory: { + type: [String], + select: false, + default: [] + }, + logs: { + type: [securityLogSchema], + default: [] + } + }, + notifications: { + email: { + type: Boolean, + default: true + }, + push: { + type: Boolean, + default: true + }, + sms: { + type: Boolean, + default: false + }, + preferences: { + bookings: { + type: Boolean, + default: true + }, + messages: { + type: Boolean, + default: true + }, + reviews: { + type: Boolean, + default: true + }, + system: { + type: Boolean, + default: true + } + } + }, + settings: { + theme: { + type: String, + enum: ['light', 'dark', 'system'], + default: 'system' + }, + language: { + type: String, + enum: ['en', 'bn'], + default: 'en' + }, + timezone: { + type: String, + default: 'Asia/Dhaka' + } + }, + lastActive: { + type: Date, + default: Date.now + }, + activityLog: { + type: [activityLogSchema], + default: [], + select: false + } +}, { + timestamps: true, + toJSON: { virtuals: true }, + toObject: { virtuals: true } +}); + +// Virtual for full name +userSchema.virtual('name').get(function() { + return `${this.firstName} ${this.lastName}`; +}); + +// Virtual for calculating account age in days +userSchema.virtual('accountAge').get(function() { + return Math.floor((Date.now() - this.createdAt) / (1000 * 60 * 60 * 24)); +}); + +// Hash password before saving +userSchema.pre('save', async function(next) { + if (!this.isModified('password')) { + return next(); + } + + try { + const salt = await bcrypt.genSalt(10); + this.password = await bcrypt.hash(this.password, salt); + + // Add to password history + if (this.security.passwordHistory.length >= 5) { + this.security.passwordHistory.pop(); + } + this.security.passwordHistory.unshift(this.password); + this.security.lastPasswordChange = new Date(); + + next(); + } catch (error) { + next(error); + } +}); + +// Password verification method +userSchema.methods.comparePassword = async function(candidatePassword) { + try { + return await bcrypt.compare(candidatePassword, this.password); + } catch (error) { + throw new Error('Error comparing passwords'); + } +}; + +// Method to get public profile +userSchema.methods.toPublicProfile = function() { + const { password, security, activityLog, ...publicProfile } = this.toObject(); + return publicProfile; +}; + +// Method to log security event +userSchema.methods.logSecurityEvent = async function(eventType, device, location, ip, details = {}) { + this.security.logs.unshift({ + eventType, + device, + location, + ip, + details, + timestamp: new Date() + }); + + if (this.security.logs.length > 50) { + this.security.logs = this.security.logs.slice(0, 50); + } + + await this.save(); +}; + +// Method to update security settings +userSchema.methods.updateSecuritySettings = async function(settings) { + Object.assign(this.security, settings); + await this.save(); + return this.security; +}; + +// Method to update notification preferences +userSchema.methods.updateNotificationPreferences = async function(preferences) { + Object.assign(this.notifications, preferences); + await this.save(); + return this.notifications; +}; + +// Method to update theme settings +userSchema.methods.updateTheme = async function(themeSettings) { + Object.assign(this.settings, themeSettings); + await this.save(); + return this.settings; +}; + +// Method to check if password change is required +userSchema.methods.isPasswordChangeRequired = function() { + if (!this.security.lastPasswordChange) return true; + + const daysSinceLastChange = Math.floor( + (Date.now() - this.security.lastPasswordChange) / (1000 * 60 * 60 * 24) + ); + + return daysSinceLastChange >= 90; // Require change every 90 days +}; + +module.exports = mongoose.model('User', userSchema); diff --git a/backend/package-lock.json b/backend/package-lock.json new file mode 100644 index 0000000..c359466 --- /dev/null +++ b/backend/package-lock.json @@ -0,0 +1,2960 @@ +{ + "name": "house-rental-backend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "house-rental-backend", + "version": "1.0.0", + "dependencies": { + "bcryptjs": "^2.4.3", + "cors": "^2.8.5", + "dotenv": "^16.0.3", + "express": "^4.18.2", + "express-async-handler": "^1.2.0", + "express-mongo-sanitize": "^2.2.0", + "express-rate-limit": "^7.4.1", + "express-validator": "^7.2.0", + "geoip-lite": "^1.4.9", + "helmet": "^8.0.0", + "jsonwebtoken": "^9.0.0", + "mongoose": "^7.0.3", + "morgan": "^1.10.0", + "multer": "^1.4.5-lts.1", + "qrcode": "^1.5.3", + "sharp": "^0.33.5", + "speakeasy": "^2.0.0", + "ua-parser-js": "^1.0.37", + "uuid": "^9.0.1", + "validator": "^13.9.0", + "xss": "^1.0.15", + "xss-clean": "^0.1.4" + }, + "devDependencies": { + "nodemon": "^3.1.7" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.3.1.tgz", + "integrity": "sha512-kEBmG8KyqtxJZv+ygbEim+KCGtIq1fC22Ms3S4ziXmYKm8uyoLX0MHONVKwp+9opg390VaKRNt4a7A9NwmpNhw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", + "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", + "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", + "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", + "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", + "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", + "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", + "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", + "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", + "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", + "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", + "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.0.5" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", + "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", + "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", + "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", + "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", + "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", + "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.2.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", + "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", + "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@mongodb-js/saslprep": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.1.9.tgz", + "integrity": "sha512-tVkljjeEaAhCqTzajSdgbQ6gE6f3oneVwa3iXR6csiEwXXOFsiC6Uh9iAjAhXPtqa/XMDHWjjeNH/77m/Yq2dw==", + "license": "MIT", + "optional": true, + "dependencies": { + "sparse-bitfield": "^3.0.3" + } + }, + "node_modules/@types/node": { + "version": "22.9.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.9.1.tgz", + "integrity": "sha512-p8Yy/8sw1caA8CdRIQBG5tiLHmxtQKObCijiAa9Ez+d4+PRffM4054xbju0msf+cvhJpnFEeNjxmVT/0ipktrg==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.19.8" + } + }, + "node_modules/@types/webidl-conversions": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", + "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==", + "license": "MIT" + }, + "node_modules/@types/whatwg-url": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-8.2.2.tgz", + "integrity": "sha512-FtQu10RWgn3D9U4aazdwIE2yzphmTJREDqNdODHrbrZmmMqI0vMheC/6NE/J1Yveaj8H+ela+YwWTjq5PGmuhA==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/webidl-conversions": "*" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/async": { + "version": "2.6.4", + "resolved": "https://registry.npmjs.org/async/-/async-2.6.4.tgz", + "integrity": "sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.14" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/base32.js": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/base32.js/-/base32.js-0.0.1.tgz", + "integrity": "sha512-EGHIRiegFa62/SsA1J+Xs2tIzludPdzM064N9wjbiEgHnGnJ1V0WEpA4pEwCYT5nDvZk3ubf0shqaCS7k6xeUQ==", + "license": "MIT" + }, + "node_modules/basic-auth": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", + "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.1.2" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/basic-auth/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/bcryptjs": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", + "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==", + "license": "MIT" + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/body-parser": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.13.0", + "raw-body": "2.5.2", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/bson": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/bson/-/bson-5.5.1.tgz", + "integrity": "sha512-ix0EwukN2EpC0SRWIj/7B5+A6uQMQy6KMREI9qQqvgpkV2frH63T0UDVd1SYedL6dNCmDBYB3QtXi4ISk9YT+g==", + "license": "Apache-2.0", + "engines": { + "node": ">=14.20.1" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", + "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, + "node_modules/concat-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", + "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", + "engines": [ + "node >= 0.8" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/cssfilter": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/cssfilter/-/cssfilter-0.0.10.tgz", + "integrity": "sha512-FAaLDaplstoRsDR8XGYH51znUN0UY7nMc6Z9/fvE8EXGwvJE9hu7W2vHwx1+bd6gCYnln9nLbzxFTrcO9YQDZw==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz", + "integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, + "node_modules/dotenv": { + "version": "16.4.5", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", + "integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", + "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.4" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.1.tgz", + "integrity": "sha512-YSFlK1Ee0/GC8QaO91tHcDxJiE/X4FbpAyQWkxAvG6AXCuR65YzK8ua6D9hvi/TzUfZMpc+BwuM1IPw8fmQBiQ==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.3", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.7.1", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.3.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.10", + "proxy-addr": "~2.0.7", + "qs": "6.13.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.19.0", + "serve-static": "1.16.2", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/express-async-handler": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/express-async-handler/-/express-async-handler-1.2.0.tgz", + "integrity": "sha512-rCSVtPXRmQSW8rmik/AIb2P0op6l7r1fMW538yyvTMltCO4xQEWMmobfrIxN2V1/mVrgxB8Az3reYF6yUZw37w==", + "license": "MIT" + }, + "node_modules/express-mongo-sanitize": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/express-mongo-sanitize/-/express-mongo-sanitize-2.2.0.tgz", + "integrity": "sha512-PZBs5nwhD6ek9ZuP+W2xmpvcrHwXZxD5GdieX2dsjPbAbH4azOkrHbycBud2QRU+YQF1CT+pki/lZGedHgo/dQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/express-rate-limit": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.4.1.tgz", + "integrity": "sha512-KS3efpnpIDVIXopMc65EMbWbUht7qvTCdtCR2dD/IZmi9MIkopYESwyRqLgv8Pfu589+KqDqOdzJWW7AHoACeg==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": "4 || 5 || ^5.0.0-beta.1" + } + }, + "node_modules/express-validator": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.2.0.tgz", + "integrity": "sha512-I2ByKD8panjtr8Y05l21Wph9xk7kk64UMyvJCl/fFM/3CTJq8isXYPLeKW/aZBCdb/LYNv63PwhY8khw8VWocA==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.21", + "validator": "~13.12.0" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/geoip-lite": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/geoip-lite/-/geoip-lite-1.4.10.tgz", + "integrity": "sha512-4N69uhpS3KFd97m00wiFEefwa+L+HT5xZbzPhwu+sDawStg6UN/dPwWtUfkQuZkGIY1Cj7wDVp80IsqNtGMi2w==", + "license": "Apache-2.0", + "dependencies": { + "async": "2.1 - 2.6.4", + "chalk": "4.1 - 4.1.2", + "iconv-lite": "0.4.13 - 0.6.3", + "ip-address": "5.8.9 - 5.9.4", + "lazy": "1.0.11", + "rimraf": "2.5.2 - 2.7.1", + "yauzl": "2.9.2 - 2.10.0" + }, + "engines": { + "node": ">=10.3.0" + } + }, + "node_modules/geoip-lite/node_modules/ip-address": { + "version": "5.9.4", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-5.9.4.tgz", + "integrity": "sha512-dHkI3/YNJq4b/qQaz+c8LuarD3pY24JqZWfjB8aZx1gtpc2MDILu9L9jpZe1sHpzo/yWFweQVn+U//FhazUxmw==", + "license": "MIT", + "dependencies": { + "jsbn": "1.1.0", + "lodash": "^4.17.15", + "sprintf-js": "1.1.2" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/geoip-lite/node_modules/sprintf-js": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.2.tgz", + "integrity": "sha512-VE0SOVEHCk7Qc8ulkWw3ntAzXuqf7S2lvwQaDLRnUeIEaKNQJzV6BwmLKhOqT61aGhfUMrXeaBk+oDGCzvhcug==", + "license": "BSD-3-Clause" + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", + "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3", + "hasown": "^2.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/gopd": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", + "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.1.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz", + "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", + "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/helmet": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.0.0.tgz", + "integrity": "sha512-VyusHLEIIO5mjQPUI1wpOAEu+wl6Q0998jzTxqUYGE45xCIcAxy3MsbEK/yyJUJ3ADeMoB6MornPH6GMWAf+Pw==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true, + "license": "ISC" + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz", + "integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==", + "license": "MIT", + "dependencies": { + "jsbn": "1.1.0", + "sprintf-js": "^1.1.3" + }, + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-arrayish": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", + "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==", + "license": "MIT" + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/jsbn": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", + "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==", + "license": "MIT" + }, + "node_modules/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "license": "MIT", + "dependencies": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/jwa": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", + "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "license": "MIT", + "dependencies": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/kareem": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/kareem/-/kareem-2.5.1.tgz", + "integrity": "sha512-7jFxRVm+jD+rkq3kY0iZDJfsO2/t4BBPeEb2qKn2lR/9KhuksYk5hxzfRYWMPV8P/x2d0kHD306YyWLzjjH+uA==", + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/lazy": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/lazy/-/lazy-1.0.11.tgz", + "integrity": "sha512-Y+CjUfLmIpoUCCRl0ub4smrYtGGr5AOa2AKOaWelGHOGz33X/Y/KizefGqbkwfz44+cnq/+9habclf8vOmu2LA==", + "license": "MIT", + "engines": { + "node": ">=0.2.0" + } + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/memory-pager": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", + "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==", + "license": "MIT", + "optional": true + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/mongodb": { + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-5.9.2.tgz", + "integrity": "sha512-H60HecKO4Bc+7dhOv4sJlgvenK4fQNqqUIlXxZYQNbfEWSALGAwGoyJd/0Qwk4TttFXUOHJ2ZJQe/52ScaUwtQ==", + "license": "Apache-2.0", + "dependencies": { + "bson": "^5.5.0", + "mongodb-connection-string-url": "^2.6.0", + "socks": "^2.7.1" + }, + "engines": { + "node": ">=14.20.1" + }, + "optionalDependencies": { + "@mongodb-js/saslprep": "^1.1.0" + }, + "peerDependencies": { + "@aws-sdk/credential-providers": "^3.188.0", + "@mongodb-js/zstd": "^1.0.0", + "kerberos": "^1.0.0 || ^2.0.0", + "mongodb-client-encryption": ">=2.3.0 <3", + "snappy": "^7.2.2" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-providers": { + "optional": true + }, + "@mongodb-js/zstd": { + "optional": true + }, + "kerberos": { + "optional": true + }, + "mongodb-client-encryption": { + "optional": true + }, + "snappy": { + "optional": true + } + } + }, + "node_modules/mongodb-connection-string-url": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-2.6.0.tgz", + "integrity": "sha512-WvTZlI9ab0QYtTYnuMLgobULWhokRjtC7db9LtcVfJ+Hsnyr5eo6ZtNAt3Ly24XZScGMelOcGtm7lSn0332tPQ==", + "license": "Apache-2.0", + "dependencies": { + "@types/whatwg-url": "^8.2.1", + "whatwg-url": "^11.0.0" + } + }, + "node_modules/mongoose": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-7.8.2.tgz", + "integrity": "sha512-/KDcZL84gg8hnmOHRRPK49WtxH3Xsph38c7YqvYPdxEB2OsDAXvwAknGxyEC0F2P3RJCqFOp+523iFCa0p3dfw==", + "license": "MIT", + "dependencies": { + "bson": "^5.5.0", + "kareem": "2.5.1", + "mongodb": "5.9.2", + "mpath": "0.9.0", + "mquery": "5.0.0", + "ms": "2.1.3", + "sift": "16.0.1" + }, + "engines": { + "node": ">=14.20.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mongoose" + } + }, + "node_modules/mongoose/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/morgan": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.0.tgz", + "integrity": "sha512-AbegBVI4sh6El+1gNwvD5YIck7nSA36weD7xvIxG4in80j/UoK8AEGaWnnz8v1GxonMCltmlNs5ZKbGvl9b1XQ==", + "license": "MIT", + "dependencies": { + "basic-auth": "~2.0.1", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-finished": "~2.3.0", + "on-headers": "~1.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/morgan/node_modules/on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/mpath": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/mpath/-/mpath-0.9.0.tgz", + "integrity": "sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mquery": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/mquery/-/mquery-5.0.0.tgz", + "integrity": "sha512-iQMncpmEK8R8ncT8HJGsGc9Dsp8xcgYMVSbs5jgnm1lFHTZqMJTUWTDx1LBO8+mK3tPNZWFLBghQEIOULSTHZg==", + "license": "MIT", + "dependencies": { + "debug": "4.x" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/mquery/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/mquery/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/multer": { + "version": "1.4.5-lts.1", + "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.1.tgz", + "integrity": "sha512-ywPWvcDMeH+z9gQq5qYHCCy+ethsk4goepZ45GLD63fOu0YcNecQxi64nDs3qluZB+murG3/D4dJ7+dGctcCQQ==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.0.0", + "concat-stream": "^1.5.2", + "mkdirp": "^0.5.4", + "object-assign": "^4.1.1", + "type-is": "^1.6.4", + "xtend": "^4.0.0" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/nodemon": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.7.tgz", + "integrity": "sha512-hLj7fuMow6f0lbB0cD14Lz2xNjwsyruH251Pk4t/yIitCFJbmY1myuLlHm/q06aST4jg6EgAh74PIBBrRqpVAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^3.1.2", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/nodemon/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/nodemon/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.3", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.3.tgz", + "integrity": "sha512-kDCGIbxkDSXE3euJZZXzc6to7fCrKHNI/hSRQnRuQ+BWjFNzZwiFF8fj/6o2t2G9/jTj8PSIYTfCLelLZEeRpA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", + "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.10", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.10.tgz", + "integrity": "sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w==", + "license": "MIT" + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "license": "MIT" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true, + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/qs": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.0.6" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, + "node_modules/rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.19.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/sharp": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", + "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.3", + "semver": "^7.6.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.33.5", + "@img/sharp-darwin-x64": "0.33.5", + "@img/sharp-libvips-darwin-arm64": "1.0.4", + "@img/sharp-libvips-darwin-x64": "1.0.4", + "@img/sharp-libvips-linux-arm": "1.0.5", + "@img/sharp-libvips-linux-arm64": "1.0.4", + "@img/sharp-libvips-linux-s390x": "1.0.4", + "@img/sharp-libvips-linux-x64": "1.0.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", + "@img/sharp-libvips-linuxmusl-x64": "1.0.4", + "@img/sharp-linux-arm": "0.33.5", + "@img/sharp-linux-arm64": "0.33.5", + "@img/sharp-linux-s390x": "0.33.5", + "@img/sharp-linux-x64": "0.33.5", + "@img/sharp-linuxmusl-arm64": "0.33.5", + "@img/sharp-linuxmusl-x64": "0.33.5", + "@img/sharp-wasm32": "0.33.5", + "@img/sharp-win32-ia32": "0.33.5", + "@img/sharp-win32-x64": "0.33.5" + } + }, + "node_modules/side-channel": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", + "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4", + "object-inspect": "^1.13.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/sift": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/sift/-/sift-16.0.1.tgz", + "integrity": "sha512-Wv6BjQ5zbhW7VFefWusVP33T/EM0vYikCaQ2qR8yULbsilAT8/wQaXvuQ3ptGLpoKx+lihJE3y2UTgKDyyNHZQ==", + "license": "MIT" + }, + "node_modules/simple-swizzle": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", + "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.3.tgz", + "integrity": "sha512-l5x7VUUWbjVFbafGLxPWkYsHIhEvmF85tbIeFZWc8ZPtoMyybuEhL7Jye/ooC4/d48FgOjSJXgsF/AJPYCW8Zw==", + "license": "MIT", + "dependencies": { + "ip-address": "^9.0.5", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/sparse-bitfield": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", + "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "memory-pager": "^1.0.2" + } + }, + "node_modules/speakeasy": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/speakeasy/-/speakeasy-2.0.0.tgz", + "integrity": "sha512-lW2A2s5LKi8rwu77ewisuUOtlCydF/hmQSOJjpTqTj1gZLkNgTaYnyvfxy2WBr4T/h+9c4g8HIITfj83OkFQFw==", + "license": "MIT", + "dependencies": { + "base32.js": "0.0.1" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "license": "BSD-3-Clause" + }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/tr46": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz", + "integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==", + "license": "MIT", + "dependencies": { + "punycode": "^2.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/ua-parser-js": { + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.39.tgz", + "integrity": "sha512-k24RCVWlEcjkdOxYmVJgeD/0a1TiSpqLg+ZalVGV9lsnr4yqu0w7tX/x2xX6G4zpkgQnRf89lxuZ1wsbjXM8lw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + }, + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + } + ], + "license": "MIT", + "bin": { + "ua-parser-js": "script/cli.js" + }, + "engines": { + "node": "*" + } + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/validator": { + "version": "13.12.0", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.12.0.tgz", + "integrity": "sha512-c1Q0mCiPlgdTVVVIJIrBuxNicYE+t/7oKeI9MWLj3fh/uq2Pxh/3eeWbVZ4OcGW1TUf53At0njHw5SMdA3tmMg==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-url": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz", + "integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==", + "license": "MIT", + "dependencies": { + "tr46": "^3.0.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/xss": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/xss/-/xss-1.0.15.tgz", + "integrity": "sha512-FVdlVVC67WOIPvfOwhoMETV72f6GbW7aOabBC3WxN/oUdoEMDyLz4OgRv5/gck2ZeNqEQu+Tb0kloovXOfpYVg==", + "license": "MIT", + "dependencies": { + "commander": "^2.20.3", + "cssfilter": "0.0.10" + }, + "bin": { + "xss": "bin/xss" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/xss-clean": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/xss-clean/-/xss-clean-0.1.4.tgz", + "integrity": "sha512-4hArTFHYxrifK9VXOu/zFvwjTXVjKByPi6woUHb1IqxlX0Z4xtFBRjOhTKuYV/uE1VswbYsIh5vUEYp7MmoISQ==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "MIT", + "dependencies": { + "xss-filters": "1.2.7" + } + }, + "node_modules/xss-filters": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/xss-filters/-/xss-filters-1.2.7.tgz", + "integrity": "sha512-KzcmYT/f+YzcYrYRqw6mXxd25BEZCxBQnf+uXTopQDIhrmiaLwO+f+yLsIvvNlPhYvgff8g3igqrBxYh9k8NbQ==" + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + } + } +} diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..2634ec2 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,37 @@ +{ + "name": "house-rental-backend", + "version": "1.0.0", + "description": "Backend for house rental application", + "main": "server.js", + "scripts": { + "start": "node server.js", + "dev": "nodemon server.js" + }, + "dependencies": { + "bcryptjs": "^2.4.3", + "cors": "^2.8.5", + "dotenv": "^16.0.3", + "express": "^4.18.2", + "express-async-handler": "^1.2.0", + "express-mongo-sanitize": "^2.2.0", + "express-rate-limit": "^7.4.1", + "express-validator": "^7.2.0", + "geoip-lite": "^1.4.9", + "helmet": "^8.0.0", + "jsonwebtoken": "^9.0.0", + "mongoose": "^7.0.3", + "morgan": "^1.10.0", + "multer": "^1.4.5-lts.1", + "qrcode": "^1.5.3", + "sharp": "^0.33.5", + "speakeasy": "^2.0.0", + "ua-parser-js": "^1.0.37", + "uuid": "^9.0.1", + "validator": "^13.9.0", + "xss": "^1.0.15", + "xss-clean": "^0.1.4" + }, + "devDependencies": { + "nodemon": "^3.1.7" + } +} diff --git a/backend/routes/admin.js b/backend/routes/admin.js new file mode 100644 index 0000000..3b2251a --- /dev/null +++ b/backend/routes/admin.js @@ -0,0 +1,627 @@ +const express = require('express'); +const router = express.Router(); +const mongoose = require('mongoose'); +const { isAdmin, auth } = require('../middleware/auth'); +const isSuperAdmin = require('../middleware/isSuperAdmin'); +const Post = require('../models/Post'); +const User = require('../models/User'); +const Property = require('../models/Property'); +const Report = require('../models/Report'); +const Settings = require('../models/Settings'); +const Chatbot = require('../models/Chatbot'); +const { sendEmail } = require('../utils/email'); + +// Middleware to ensure only admins can access these routes +router.use(auth, isAdmin); + +// Get enhanced admin dashboard statistics +router.get('/dashboard', async (req, res) => { + try { + const now = new Date(); + const thirtyDaysAgo = new Date(now.getTime() - (30 * 24 * 60 * 60 * 1000)); + const previousThirtyDays = new Date(thirtyDaysAgo.getTime() - (30 * 24 * 60 * 60 * 1000)); + + const [ + currentStats, + previousStats, + propertyStats, + userStats, + bookingStats + ] = await Promise.all([ + // Current period stats + Property.aggregate([ + { $match: { createdAt: { $gte: thirtyDaysAgo } } }, + { $group: { _id: null, count: { $sum: 1 } } } + ]), + // Previous period stats for comparison + Property.aggregate([ + { $match: { createdAt: { $gte: previousThirtyDays, $lt: thirtyDaysAgo } } }, + { $group: { _id: null, count: { $sum: 1 } } } + ]), + // Property statistics + Property.aggregate([ + { + $facet: { + byStatus: [ + { $group: { _id: "$status", count: { $sum: 1 } } } + ], + byType: [ + { $group: { _id: "$propertyType", count: { $sum: 1 } } } + ], + byLocation: [ + { $group: { _id: "$location.city", count: { $sum: 1 } } } + ] + } + } + ]), + // User statistics + User.aggregate([ + { + $facet: { + byRole: [ + { $group: { _id: "$role", count: { $sum: 1 } } } + ], + byStatus: [ + { $group: { _id: "$status", count: { $sum: 1 } } } + ], + recentlyActive: [ + { $match: { lastActive: { $gte: thirtyDaysAgo } } }, + { $count: "count" } + ] + } + } + ]), + // Booking statistics + mongoose.model('Booking').aggregate([ + { + $facet: { + byStatus: [ + { $group: { _id: "$status", count: { $sum: 1 } } } + ], + recent: [ + { $match: { createdAt: { $gte: thirtyDaysAgo } } }, + { $count: "count" } + ] + } + } + ]) + ]); + + const currentCount = currentStats[0]?.count || 0; + const previousCount = previousStats[0]?.count || 0; + const growthRate = previousCount === 0 ? 100 : ((currentCount - previousCount) / previousCount) * 100; + + res.json({ + stats: { + properties: currentCount, + propertyGrowth: growthRate.toFixed(1) + '%', + users: userStats[0]?.byRole.reduce((acc, curr) => acc + curr.count, 0) || 0, + bookings: bookingStats[0]?.recent[0]?.count || 0, + }, + propertyStats: propertyStats[0], + userStats: userStats[0], + bookingStats: bookingStats[0] + }); + } catch (error) { + console.error('Error fetching enhanced admin stats:', error); + res.status(500).json({ message: 'Error fetching admin statistics' }); + } +}); + +// Get chatbot analytics +router.get('/chatbot/analytics', async (req, res) => { + try { + const { timeRange = '7d' } = req.query; + const now = new Date(); + let startDate; + + switch (timeRange) { + case '24h': + startDate = new Date(now.getTime() - (24 * 60 * 60 * 1000)); + break; + case '7d': + startDate = new Date(now.getTime() - (7 * 24 * 60 * 60 * 1000)); + break; + case '30d': + startDate = new Date(now.getTime() - (30 * 24 * 60 * 60 * 1000)); + break; + case '90d': + startDate = new Date(now.getTime() - (90 * 24 * 60 * 60 * 1000)); + break; + default: + startDate = new Date(now.getTime() - (7 * 24 * 60 * 60 * 1000)); + } + + const previousPeriod = new Date(startDate.getTime() - (startDate.getTime() - now.getTime())); + + const [currentStats, previousStats] = await Promise.all([ + Chatbot.aggregate([ + { $match: { timestamp: { $gte: startDate } } }, + { + $group: { + _id: null, + totalInteractions: { $sum: 1 }, + successfulInteractions: { $sum: { $cond: ["$successful", 1, 0] } }, + totalResponseTime: { $sum: "$responseTime" }, + humanHandoffs: { $sum: { $cond: ["$handedOffToHuman", 1, 0] } } + } + } + ]), + Chatbot.aggregate([ + { $match: { timestamp: { $gte: previousPeriod, $lt: startDate } } }, + { + $group: { + _id: null, + totalInteractions: { $sum: 1 }, + successfulInteractions: { $sum: { $cond: ["$successful", 1, 0] } }, + totalResponseTime: { $sum: "$responseTime" }, + humanHandoffs: { $sum: { $cond: ["$handedOffToHuman", 1, 0] } } + } + } + ]) + ]); + + const current = currentStats[0] || { + totalInteractions: 0, + successfulInteractions: 0, + totalResponseTime: 0, + humanHandoffs: 0 + }; + const previous = previousStats[0] || { + totalInteractions: 0, + successfulInteractions: 0, + totalResponseTime: 0, + humanHandoffs: 0 + }; + + const calculateChange = (curr, prev) => { + if (prev === 0) return '+100'; + return ((curr - prev) / prev * 100).toFixed(1); + }; + + res.json({ + totalInteractions: current.totalInteractions, + interactionsChange: calculateChange(current.totalInteractions, previous.totalInteractions), + successRate: current.totalInteractions ? current.successfulInteractions / current.totalInteractions : 0, + successRateChange: calculateChange( + current.totalInteractions ? current.successfulInteractions / current.totalInteractions : 0, + previous.totalInteractions ? previous.successfulInteractions / previous.totalInteractions : 0 + ), + avgResponseTime: current.totalInteractions ? Math.round(current.totalResponseTime / current.totalInteractions) : 0, + responseTimeChange: calculateChange( + current.totalInteractions ? current.totalResponseTime / current.totalInteractions : 0, + previous.totalInteractions ? previous.totalResponseTime / previous.totalInteractions : 0 + ), + humanHandoffs: current.humanHandoffs, + handoffChange: calculateChange(current.humanHandoffs, previous.humanHandoffs) + }); + } catch (error) { + console.error('Error fetching chatbot analytics:', error); + res.status(500).json({ message: 'Error fetching chatbot analytics' }); + } +}); + +// Get property locations for map +router.get('/properties/locations', async (req, res) => { + try { + const properties = await Property.find( + { 'location.coordinates': { $exists: true } }, + { + title: 1, + 'location.coordinates': 1, + 'location.address': 1, + price: 1, + propertyType: 1, + status: 1 + } + ).limit(1000); + + res.json(properties); + } catch (error) { + console.error('Error fetching property locations:', error); + res.status(500).json({ message: 'Error fetching property locations' }); + } +}); + +// Get admin dashboard statistics +router.get('/stats', async (req, res) => { + try { + const [ + pendingCount, + reportedPostsCount, + reportedUsersCount, + totalApproved, + totalRejected + ] = await Promise.all([ + Post.countDocuments({ status: 'pending' }), + Post.countDocuments({ 'reports.0': { $exists: true } }), + User.countDocuments({ 'reports.0': { $exists: true } }), + Post.countDocuments({ status: 'approved' }), + Post.countDocuments({ status: 'rejected' }) + ]); + + res.json({ + pendingCount, + reportedPostsCount, + reportedUsersCount, + totalApproved, + totalRejected + }); + } catch (error) { + console.error('Error fetching admin stats:', error); + res.status(500).json({ message: 'Error fetching admin statistics' }); + } +}); + +// Get pending posts +router.get('/pending-posts', async (req, res) => { + try { + const posts = await Post.find({ status: 'pending' }) + .populate('owner', 'name email avatar') + .sort({ createdAt: -1 }); + + res.json(posts); + } catch (error) { + console.error('Error fetching pending posts:', error); + res.status(500).json({ message: 'Error fetching pending posts' }); + } +}); + +// Get reported posts +router.get('/reported-posts', async (req, res) => { + try { + const posts = await Post.find({ 'reports.0': { $exists: true } }) + .populate('owner', 'name email avatar') + .populate('reports.reportedBy', 'name email') + .sort({ 'reports.length': -1 }); + + res.json(posts); + } catch (error) { + console.error('Error fetching reported posts:', error); + res.status(500).json({ message: 'Error fetching reported posts' }); + } +}); + +// Get reported users +router.get('/reported-users', async (req, res) => { + try { + const users = await User.find({ 'reports.0': { $exists: true } }) + .select('-password') + .populate('reports.reportedBy', 'name email') + .sort({ 'reports.length': -1 }); + + res.json(users); + } catch (error) { + console.error('Error fetching reported users:', error); + res.status(500).json({ message: 'Error fetching reported users' }); + } +}); + +// Approve a post +router.post('/posts/:postId/approve', async (req, res) => { + try { + const post = await Post.findById(req.params.postId) + .populate('owner', 'email name'); + + if (!post) { + return res.status(404).json({ message: 'Post not found' }); + } + + post.status = 'approved'; + post.moderationNotes = req.body.reason || 'Approved by admin'; + post.moderatedAt = new Date(); + post.moderatedBy = req.user._id; + + await post.save(); + + // Send email notification to post owner + await sendEmail({ + to: post.owner.email, + subject: 'Your Post Has Been Approved', + text: `Dear ${post.owner.name},\n\nYour property listing "${post.title}" has been approved and is now live on our platform.\n\nBest regards,\nThe House Rental Team` + }); + + res.json({ message: 'Post approved successfully', post }); + } catch (error) { + console.error('Error approving post:', error); + res.status(500).json({ message: 'Error approving post' }); + } +}); + +// Reject a post +router.post('/posts/:postId/reject', async (req, res) => { + try { + const post = await Post.findById(req.params.postId) + .populate('owner', 'email name'); + + if (!post) { + return res.status(404).json({ message: 'Post not found' }); + } + + post.status = 'rejected'; + post.moderationNotes = req.body.reason || 'Rejected by admin'; + post.moderatedAt = new Date(); + post.moderatedBy = req.user._id; + + await post.save(); + + // Send email notification to post owner + await sendEmail({ + to: post.owner.email, + subject: 'Your Post Has Been Rejected', + text: `Dear ${post.owner.name},\n\nUnfortunately, your property listing "${post.title}" has been rejected.\n\nReason: ${post.moderationNotes}\n\nIf you believe this is a mistake, please contact our support team.\n\nBest regards,\nThe House Rental Team` + }); + + res.json({ message: 'Post rejected successfully', post }); + } catch (error) { + console.error('Error rejecting post:', error); + res.status(500).json({ message: 'Error rejecting post' }); + } +}); + +// Ban a user +router.post('/users/:userId/ban', async (req, res) => { + try { + const user = await User.findById(req.params.userId); + + if (!user) { + return res.status(404).json({ message: 'User not found' }); + } + + user.status = 'banned'; + user.banReason = req.body.reason || 'Banned by admin'; + user.bannedAt = new Date(); + user.bannedBy = req.user._id; + + await user.save(); + + // Delete all pending posts by the banned user + await Post.updateMany( + { owner: user._id, status: 'pending' }, + { status: 'rejected', moderationNotes: 'User banned' } + ); + + // Send email notification to banned user + await sendEmail({ + to: user.email, + subject: 'Account Banned', + text: `Dear ${user.name},\n\nYour account has been banned from our platform.\n\nReason: ${user.banReason}\n\nIf you believe this is a mistake, please contact our support team.\n\nBest regards,\nThe House Rental Team` + }); + + res.json({ message: 'User banned successfully', user }); + } catch (error) { + console.error('Error banning user:', error); + res.status(500).json({ message: 'Error banning user' }); + } +}); + +// Clear reports for a post +router.post('/posts/:postId/clear-reports', async (req, res) => { + try { + const post = await Post.findById(req.params.postId); + + if (!post) { + return res.status(404).json({ message: 'Post not found' }); + } + + post.reports = []; + await post.save(); + + res.json({ message: 'Reports cleared successfully', post }); + } catch (error) { + console.error('Error clearing reports:', error); + res.status(500).json({ message: 'Error clearing reports' }); + } +}); + +// Clear reports for a user +router.post('/users/:userId/clear-reports', async (req, res) => { + try { + const user = await User.findById(req.params.userId); + + if (!user) { + return res.status(404).json({ message: 'User not found' }); + } + + user.reports = []; + await user.save(); + + res.json({ message: 'Reports cleared successfully', user }); + } catch (error) { + console.error('Error clearing reports:', error); + res.status(500).json({ message: 'Error clearing reports' }); + } +}); + +// Super Admin Routes +router.use('/super', auth, isSuperAdmin); + +// Get all admins +router.get('/super/admins', async (req, res) => { + try { + const admins = await User.find({ role: { $in: ['admin', 'superadmin'] } }) + .select('-password') + .sort({ createdAt: -1 }); + + res.json(admins); + } catch (error) { + console.error('Error fetching admins:', error); + res.status(500).json({ message: 'Error fetching admins' }); + } +}); + +// Assign admin role +router.post('/super/admins/assign/:userId', async (req, res) => { + try { + const { userId } = req.params; + const { role } = req.body; + + if (!['admin', 'superadmin'].includes(role)) { + return res.status(400).json({ message: 'Invalid role' }); + } + + const user = await User.findById(userId); + if (!user) { + return res.status(404).json({ message: 'User not found' }); + } + + user.role = role; + await user.save(); + + // Send email notification + await sendEmail({ + to: user.email, + subject: 'Admin Role Assignment', + text: `Dear ${user.name},\n\nYou have been assigned the role of ${role} on our platform.\n\nBest regards,\nThe House Rental Team` + }); + + res.json({ message: 'Admin role assigned successfully', user }); + } catch (error) { + console.error('Error assigning admin role:', error); + res.status(500).json({ message: 'Error assigning admin role' }); + } +}); + +// Remove admin role +router.post('/super/admins/remove/:userId', async (req, res) => { + try { + const { userId } = req.params; + + // Prevent removing the last super admin + if (req.user._id.toString() === userId) { + return res.status(400).json({ message: 'Cannot remove your own admin role' }); + } + + const superAdminCount = await User.countDocuments({ role: 'superadmin' }); + const user = await User.findById(userId); + + if (user.role === 'superadmin' && superAdminCount <= 1) { + return res.status(400).json({ message: 'Cannot remove the last super admin' }); + } + + user.role = 'user'; + await user.save(); + + // Send email notification + await sendEmail({ + to: user.email, + subject: 'Admin Role Removed', + text: `Dear ${user.name},\n\nYour admin privileges have been removed from our platform.\n\nBest regards,\nThe House Rental Team` + }); + + res.json({ message: 'Admin role removed successfully', user }); + } catch (error) { + console.error('Error removing admin role:', error); + res.status(500).json({ message: 'Error removing admin role' }); + } +}); + +// Get system settings +router.get('/super/settings', async (req, res) => { + try { + const settings = await Settings.getInstance(); + res.json(settings); + } catch (error) { + console.error('Error fetching settings:', error); + res.status(500).json({ message: 'Error fetching settings' }); + } +}); + +// Update system settings +router.put('/super/settings', async (req, res) => { + try { + const settings = await Settings.getInstance(); + const updates = req.body; + + // Validate and update each setting + for (const [key, value] of Object.entries(updates)) { + if (key in settings) { + settings[key] = value; + } + } + + await settings.save(); + res.json({ message: 'Settings updated successfully', settings }); + } catch (error) { + console.error('Error updating settings:', error); + res.status(500).json({ message: 'Error updating settings' }); + } +}); + +// Get admin activity logs +router.get('/super/activity-logs', async (req, res) => { + try { + const { startDate, endDate, adminId } = req.query; + const query = { + moderatedBy: adminId ? mongoose.Types.ObjectId(adminId) : { $exists: true }, + moderatedAt: {} + }; + + if (startDate) { + query.moderatedAt.$gte = new Date(startDate); + } + if (endDate) { + query.moderatedAt.$lte = new Date(endDate); + } + + const logs = await Post.find(query) + .select('title status moderationNotes moderatedBy moderatedAt') + .populate('moderatedBy', 'name email') + .sort({ moderatedAt: -1 }) + .limit(100); + + res.json(logs); + } catch (error) { + console.error('Error fetching activity logs:', error); + res.status(500).json({ message: 'Error fetching activity logs' }); + } +}); + +// Get system health metrics +router.get('/super/health', async (req, res) => { + try { + const [ + totalUsers, + totalPosts, + totalReports, + activeUsers24h, + newUsers24h, + storageUsed + ] = await Promise.all([ + User.countDocuments(), + Post.countDocuments(), + Report.countDocuments(), + User.countDocuments({ + lastLoginAt: { $gte: new Date(Date.now() - 24 * 60 * 60 * 1000) } + }), + User.countDocuments({ + createdAt: { $gte: new Date(Date.now() - 24 * 60 * 60 * 1000) } + }), + Post.aggregate([ + { + $group: { + _id: null, + totalSize: { $sum: { $size: '$images' } } + } + } + ]) + ]); + + res.json({ + totalUsers, + totalPosts, + totalReports, + activeUsers24h, + newUsers24h, + storageUsed: storageUsed[0]?.totalSize || 0, + serverUptime: process.uptime(), + nodeVersion: process.version, + memoryUsage: process.memoryUsage() + }); + } catch (error) { + console.error('Error fetching system health:', error); + res.status(500).json({ message: 'Error fetching system health' }); + } +}); + +module.exports = router; diff --git a/backend/routes/auth.js b/backend/routes/auth.js new file mode 100644 index 0000000..10fea12 --- /dev/null +++ b/backend/routes/auth.js @@ -0,0 +1,69 @@ +const express = require('express'); +const router = express.Router(); +const { + register, + login, + getMe, + changePassword, + deleteAccount, + createAdmin, + updateUserRole, + forgotPassword, + resetPassword, + verifyEmail, + resendVerification +} = require('../controllers/authController'); +const { protect, authorize } = require('../middleware/auth'); +const { body } = require('express-validator'); +const asyncHandler = require('express-async-handler'); + +// Input validation middleware +const validateRegistration = [ + body('firstName').trim().notEmpty().withMessage('First name is required'), + body('lastName').trim().notEmpty().withMessage('Last name is required'), + body('email').isEmail().withMessage('Please enter a valid email'), + body('password') + .isLength({ min: 8 }) + .withMessage('Password must be at least 8 characters long') + .matches(/^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[^a-zA-Z0-9]).{8,}$/) + .withMessage('Password must contain at least one uppercase letter, one lowercase letter, one number and one special character'), + body('phone') + .matches(/^(?:\+?88)?01[3-9]\d{8}$/) + .withMessage('Please enter a valid Bangladesh phone number'), + body('nid').trim().notEmpty().withMessage('National ID is required'), + body('address').trim().notEmpty().withMessage('Address is required'), + body('role').optional().isIn(['user', 'renter']).withMessage('Invalid role') +]; + +const validateLogin = [ + body('email').isEmail().withMessage('Please enter a valid email'), + body('password').notEmpty().withMessage('Password is required') +]; + +const validatePasswordChange = [ + body('currentPassword').notEmpty().withMessage('Current password is required'), + body('newPassword') + .isLength({ min: 8 }) + .withMessage('New password must be at least 8 characters long') + .matches(/^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[^a-zA-Z0-9]).{8,}$/) + .withMessage('New password must contain at least one uppercase letter, one lowercase letter, one number and one special character') +]; + +// Public routes +router.post('/register', validateRegistration, asyncHandler(register)); +router.post('/login', validateLogin, asyncHandler(login)); +router.post('/forgot-password', asyncHandler(forgotPassword)); +router.post('/reset-password/:token', asyncHandler(resetPassword)); +router.get('/verify-email/:token', asyncHandler(verifyEmail)); +router.post('/resend-verification', asyncHandler(resendVerification)); + +// Protected routes +router.get('/me', protect, asyncHandler(getMe)); +router.post('/change-password', protect, validatePasswordChange, asyncHandler(changePassword)); +router.delete('/delete-account', protect, asyncHandler(deleteAccount)); + +// Admin only routes +router.post('/create-admin', protect, authorize('super-admin'), asyncHandler(createAdmin)); +router.put('/update-role/:userId', protect, authorize('super-admin'), asyncHandler(updateUserRole)); + +module.exports = router; diff --git a/backend/routes/authRoutes.js b/backend/routes/authRoutes.js new file mode 100644 index 0000000..10b732f --- /dev/null +++ b/backend/routes/authRoutes.js @@ -0,0 +1,23 @@ +const express = require('express'); +const router = express.Router(); +const { protect, authorize } = require('../middleware/auth'); +const { + register, + login, + getMe, + createAdmin, + updateUserRole +} = require('../controllers/authController'); + +// Public routes +router.post('/register', register); +router.post('/login', login); + +// Protected routes +router.get('/me', protect, getMe); + +// Super Admin only routes +router.post('/create-admin', protect, authorize('super-admin'), createAdmin); +router.put('/update-role/:userId', protect, authorize('super-admin'), updateUserRole); + +module.exports = router; diff --git a/backend/routes/bookings.js b/backend/routes/bookings.js new file mode 100644 index 0000000..ce6c99a --- /dev/null +++ b/backend/routes/bookings.js @@ -0,0 +1,33 @@ +const express = require('express'); +const router = express.Router(); +const { protect, authorize } = require('../middleware/auth'); +const { + createBooking, + getBookings, + getBooking, + updateBooking, + deleteBooking, + updateBookingStatus +} = require('../controllers/bookingController'); + +router.use(protect); // Protect all routes + +// Base routes +router + .route('/') + .get(getBookings) + .post(authorize('tenant'), createBooking); + +// Single booking routes +router + .route('/:id') + .get(getBooking) + .put(authorize('tenant', 'admin'), updateBooking) + .delete(authorize('tenant', 'admin'), deleteBooking); + +// Status update route +router + .route('/:id/status') + .put(authorize('owner', 'admin'), updateBookingStatus); + +module.exports = router; diff --git a/backend/routes/chat.js b/backend/routes/chat.js new file mode 100644 index 0000000..74f8831 --- /dev/null +++ b/backend/routes/chat.js @@ -0,0 +1,288 @@ +const express = require('express'); +const router = express.Router(); +const multer = require('multer'); +const { auth } = require('../middleware/auth'); +const Message = require('../models/Message'); +const chatService = require('../services/chatService'); + +// Configure multer for memory storage +const upload = multer({ + storage: multer.memoryStorage(), + limits: { + fileSize: 10 * 1024 * 1024, // 10MB limit + }, +}); + +// Get chat messages +router.get('/:propertyId/:recipientId', auth, async (req, res) => { + try { + const { propertyId, recipientId } = req.params; + const userId = req.user._id; + + // Create chatId from sorted user IDs + const chatId = [userId, recipientId].sort().join('_'); + + const messages = await Message.find({ + propertyId, + chatId, + isDeleted: false, + }) + .sort({ createdAt: -1 }) + .limit(50) + .populate('sender', 'name avatar') + .populate('recipient', 'name avatar') + .populate('replyTo', 'content'); + + // Mark messages as read + await Message.updateMany( + { + chatId, + recipient: userId, + read: false, + }, + { + $set: { + read: true, + readAt: new Date(), + deliveryStatus: 'read', + }, + $addToSet: { + readBy: { user: userId, readAt: new Date() }, + }, + } + ); + + res.json({ messages: messages.reverse() }); + } catch (error) { + console.error('Error fetching messages:', error); + res.status(500).json({ message: 'Error fetching messages' }); + } +}); + +// Upload file attachment +router.post('/upload', auth, upload.single('file'), async (req, res) => { + try { + const file = req.file; + if (!file) { + return res.status(400).json({ message: 'No file uploaded' }); + } + + let attachment; + const fileType = req.body.type || 'file'; + + switch (fileType) { + case 'image': + attachment = await chatService.processImage(file); + break; + case 'voice': + attachment = await chatService.processVoiceMessage(file); + break; + default: + attachment = await chatService.processFile(file); + } + + res.json({ attachment }); + } catch (error) { + console.error('Error uploading file:', error); + res.status(500).json({ message: 'Error uploading file' }); + } +}); + +// Get link preview +router.post('/link-preview', auth, async (req, res) => { + try { + const { url } = req.body; + if (!url) { + return res.status(400).json({ message: 'URL is required' }); + } + + const preview = await chatService.getLinkPreview(url); + res.json(preview); + } catch (error) { + console.error('Error generating link preview:', error); + res.status(500).json({ message: 'Error generating link preview' }); + } +}); + +// Add reaction to message +router.post('/messages/:messageId/reactions', auth, async (req, res) => { + try { + const { messageId } = req.params; + const { emoji } = req.body; + const userId = req.user._id; + + const message = await Message.findById(messageId); + if (!message) { + return res.status(404).json({ message: 'Message not found' }); + } + + await message.addReaction(userId, emoji); + res.json({ message: 'Reaction added' }); + } catch (error) { + console.error('Error adding reaction:', error); + res.status(500).json({ message: 'Error adding reaction' }); + } +}); + +// Remove reaction from message +router.delete('/messages/:messageId/reactions', auth, async (req, res) => { + try { + const { messageId } = req.params; + const userId = req.user._id; + + const message = await Message.findById(messageId); + if (!message) { + return res.status(404).json({ message: 'Message not found' }); + } + + await message.removeReaction(userId); + res.json({ message: 'Reaction removed' }); + } catch (error) { + console.error('Error removing reaction:', error); + res.status(500).json({ message: 'Error removing reaction' }); + } +}); + +// Edit message +router.put('/messages/:messageId', auth, async (req, res) => { + try { + const { messageId } = req.params; + const { content } = req.body; + const userId = req.user._id; + + const message = await Message.findOne({ + _id: messageId, + sender: userId, + }); + + if (!message) { + return res.status(404).json({ message: 'Message not found' }); + } + + // Check if message is too old to edit (e.g., 24 hours) + const editWindow = 24 * 60 * 60 * 1000; // 24 hours in milliseconds + if (Date.now() - message.createdAt > editWindow) { + return res.status(400).json({ message: 'Message is too old to edit' }); + } + + await message.edit(content); + res.json({ message: 'Message updated' }); + } catch (error) { + console.error('Error editing message:', error); + res.status(500).json({ message: 'Error editing message' }); + } +}); + +// Delete message +router.delete('/messages/:messageId', auth, async (req, res) => { + try { + const { messageId } = req.params; + const userId = req.user._id; + + const message = await Message.findOne({ + _id: messageId, + sender: userId, + }); + + if (!message) { + return res.status(404).json({ message: 'Message not found' }); + } + + message.isDeleted = true; + message.deletedAt = new Date(); + await message.save(); + + res.json({ message: 'Message deleted' }); + } catch (error) { + console.error('Error deleting message:', error); + res.status(500).json({ message: 'Error deleting message' }); + } +}); + +// Get chat analytics +router.get('/:chatId/analytics', auth, async (req, res) => { + try { + const { chatId } = req.params; + const { timeRange } = req.query; + + const analytics = await chatService.getChatAnalytics(chatId, timeRange); + res.json(analytics); + } catch (error) { + console.error('Error fetching chat analytics:', error); + res.status(500).json({ message: 'Error fetching chat analytics' }); + } +}); + +// Export chat history +router.get('/:chatId/export', auth, async (req, res) => { + try { + const { chatId } = req.params; + const { format = 'json' } = req.query; + + const data = await chatService.exportChatHistory(chatId, format); + + const filename = `chat-export-${chatId}-${Date.now()}.${format}`; + res.setHeader('Content-Disposition', `attachment; filename=${filename}`); + res.setHeader('Content-Type', format === 'json' ? 'application/json' : 'text/csv'); + res.send(data); + } catch (error) { + console.error('Error exporting chat history:', error); + res.status(500).json({ message: 'Error exporting chat history' }); + } +}); + +// Report message +router.post('/messages/:messageId/report', auth, async (req, res) => { + try { + const { messageId } = req.params; + const { reason } = req.body; + const userId = req.user._id; + + const message = await Message.findById(messageId); + if (!message) { + return res.status(404).json({ message: 'Message not found' }); + } + + // Check if user has already reported this message + if (message.reports.some(report => report.user.toString() === userId.toString())) { + return res.status(400).json({ message: 'You have already reported this message' }); + } + + message.isReported = true; + message.reports.push({ + user: userId, + reason, + }); + + await message.save(); + res.json({ message: 'Message reported' }); + } catch (error) { + console.error('Error reporting message:', error); + res.status(500).json({ message: 'Error reporting message' }); + } +}); + +// Toggle message bookmark +router.post('/messages/:messageId/bookmark', auth, async (req, res) => { + try { + const { messageId } = req.params; + const userId = req.user._id; + + const message = await Message.findById(messageId); + if (!message) { + return res.status(404).json({ message: 'Message not found' }); + } + + message.isBookmarked = !message.isBookmarked; + await message.save(); + + res.json({ + message: message.isBookmarked ? 'Message bookmarked' : 'Message unbookmarked', + }); + } catch (error) { + console.error('Error toggling bookmark:', error); + res.status(500).json({ message: 'Error toggling bookmark' }); + } +}); + +module.exports = router; diff --git a/backend/routes/chatbot.js b/backend/routes/chatbot.js new file mode 100644 index 0000000..7dfb2e8 --- /dev/null +++ b/backend/routes/chatbot.js @@ -0,0 +1,12 @@ +const express = require('express'); +const router = express.Router(); +const { protect } = require('../middleware/auth'); +const { processMessage, saveFeedback } = require('../controllers/chatbotController'); + +// Process messages +router.post('/', protect, processMessage); + +// Save feedback +router.post('/feedback', protect, saveFeedback); + +module.exports = router; diff --git a/backend/routes/notificationRoutes.js b/backend/routes/notificationRoutes.js new file mode 100644 index 0000000..e9097c0 --- /dev/null +++ b/backend/routes/notificationRoutes.js @@ -0,0 +1,61 @@ +const express = require('express'); +const router = express.Router(); +const { protect } = require('../middleware/auth'); +const { body } = require('express-validator'); +const asyncHandler = require('express-async-handler'); +const Notification = require('../models/Notification'); +const { + getNotifications, + getPreferences, + updatePreferences, + markAsRead, + deleteNotification +} = require('../controllers/notificationController'); + +// Input validation middleware +const validatePreferences = [ + body('email').isBoolean().optional(), + body('push').isBoolean().optional(), + body('sms').isBoolean().optional(), + body('sound').isBoolean().optional(), + body('bookings').isBoolean().optional(), + body('messages').isBoolean().optional(), + body('propertyUpdates').isBoolean().optional(), + body('marketing').isBoolean().optional() +]; + +// Get unread notification count +router.get('/unread/count', protect, asyncHandler(async (req, res) => { + const count = await Notification.getUnreadCount(req.user.id); + res.json({ + success: true, + data: { count } + }); +})); + +// Mark all notifications as read +router.put('/read/all', protect, asyncHandler(async (req, res) => { + await Notification.markAllAsRead(req.user.id); + res.json({ + success: true, + message: 'All notifications marked as read' + }); +})); + +// Delete all notifications +router.delete('/all', protect, asyncHandler(async (req, res) => { + await Notification.deleteMany({ user: req.user.id }); + res.json({ + success: true, + message: 'All notifications deleted' + }); +})); + +// Main routes +router.get('/', protect, getNotifications); +router.get('/preferences', protect, getPreferences); +router.put('/preferences', protect, validatePreferences, updatePreferences); +router.put('/:id/read', protect, markAsRead); +router.delete('/:id', protect, deleteNotification); + +module.exports = router; diff --git a/backend/routes/posts.js b/backend/routes/posts.js new file mode 100644 index 0000000..47473e8 --- /dev/null +++ b/backend/routes/posts.js @@ -0,0 +1,221 @@ +const express = require('express'); +const router = express.Router(); +const Post = require('../models/Post'); +const { protect, authorize } = require('../middleware/auth'); +const asyncHandler = require('../middleware/async'); + +// @desc Create a new post +// @route POST /api/posts +// @access Private (Renters only) +router.post( + '/', + protect, + authorize('renter'), + asyncHandler(async (req, res) => { + // Add user id to request body + req.body.ownerId = req.user.id; + + const post = await Post.create(req.body); + + res.status(201).json({ + success: true, + data: post + }); + }) +); + +// @desc Get all approved posts +// @route GET /api/posts +// @access Public +router.get( + '/', + asyncHandler(async (req, res) => { + // Build query + const query = { + approved: true + }; + + // Add search functionality + if (req.query.search) { + query.$text = { $search: req.query.search }; + } + + // Add location filter + if (req.query.location) { + query.location = { $regex: req.query.location, $options: 'i' }; + } + + // Add price range filter + if (req.query.minPrice || req.query.maxPrice) { + query.price = {}; + if (req.query.minPrice) query.price.$gte = Number(req.query.minPrice); + if (req.query.maxPrice) query.price.$lte = Number(req.query.maxPrice); + } + + // Pagination + const page = parseInt(req.query.page, 10) || 1; + const limit = parseInt(req.query.limit, 10) || 10; + const startIndex = (page - 1) * limit; + const endIndex = page * limit; + const total = await Post.countDocuments(query); + + // Execute query + const posts = await Post.find(query) + .sort({ createdAt: -1 }) + .skip(startIndex) + .limit(limit); + + // Pagination result + const pagination = {}; + + if (endIndex < total) { + pagination.next = { + page: page + 1, + limit + }; + } + + if (startIndex > 0) { + pagination.prev = { + page: page - 1, + limit + }; + } + + res.status(200).json({ + success: true, + count: posts.length, + pagination, + data: posts + }); + }) +); + +// @desc Approve or disapprove a post +// @route PUT /api/posts/approve/:id +// @access Private (Admin only) +router.put( + '/approve/:id', + protect, + authorize('admin'), + asyncHandler(async (req, res) => { + const { approved } = req.body; + + if (typeof approved !== 'boolean') { + return res.status(400).json({ + success: false, + error: 'Please provide approved status as boolean' + }); + } + + const post = await Post.findById(req.params.id); + + if (!post) { + return res.status(404).json({ + success: false, + error: 'Post not found' + }); + } + + post.approved = approved; + await post.save(); + + res.status(200).json({ + success: true, + data: post + }); + }) +); + +// Additional useful routes + +// @desc Get posts by current user +// @route GET /api/posts/me +// @access Private +router.get( + '/me', + protect, + asyncHandler(async (req, res) => { + const posts = await Post.find({ ownerId: req.user.id }).sort({ createdAt: -1 }); + + res.status(200).json({ + success: true, + count: posts.length, + data: posts + }); + }) +); + +// @desc Update a post +// @route PUT /api/posts/:id +// @access Private (Post owner or Admin) +router.put( + '/:id', + protect, + asyncHandler(async (req, res) => { + let post = await Post.findById(req.params.id); + + if (!post) { + return res.status(404).json({ + success: false, + error: 'Post not found' + }); + } + + // Make sure user is post owner or admin + if (post.ownerId.toString() !== req.user.id && req.user.role !== 'admin') { + return res.status(401).json({ + success: false, + error: 'Not authorized to update this post' + }); + } + + // Don't allow updating the approval status through this route + delete req.body.approved; + + post = await Post.findByIdAndUpdate(req.params.id, req.body, { + new: true, + runValidators: true + }); + + res.status(200).json({ + success: true, + data: post + }); + }) +); + +// @desc Delete a post +// @route DELETE /api/posts/:id +// @access Private (Post owner or Admin) +router.delete( + '/:id', + protect, + asyncHandler(async (req, res) => { + const post = await Post.findById(req.params.id); + + if (!post) { + return res.status(404).json({ + success: false, + error: 'Post not found' + }); + } + + // Make sure user is post owner or admin + if (post.ownerId.toString() !== req.user.id && req.user.role !== 'admin') { + return res.status(401).json({ + success: false, + error: 'Not authorized to delete this post' + }); + } + + await post.remove(); + + res.status(200).json({ + success: true, + data: {} + }); + }) +); + +module.exports = router; diff --git a/backend/routes/properties.js b/backend/routes/properties.js new file mode 100644 index 0000000..684b6b8 --- /dev/null +++ b/backend/routes/properties.js @@ -0,0 +1,48 @@ +const express = require('express'); +const router = express.Router(); +const { protect, authorize } = require('../middleware/auth'); +const upload = require('../middleware/upload'); +const { + getProperties, + getProperty, + createProperty, + updateProperty, + deleteProperty, + getPropertiesInRadius, + addPropertyRating +} = require('../controllers/propertyController'); +const { + uploadPropertyImages, + deletePropertyImage +} = require('../controllers/imageController'); + +// Public routes +router.get('/radius/:zipcode/:distance', getPropertiesInRadius); +router.get('/', getProperties); +router.get('/:id', getProperty); + +// Protected routes +router.use(protect); + +// Renter routes +router.post('/', authorize('renter', 'admin'), createProperty); +router.put('/:id', authorize('renter', 'admin'), updateProperty); +router.delete('/:id', authorize('renter', 'admin'), deleteProperty); + +// Image upload routes +router.post( + '/:id/images', + authorize('renter', 'admin'), + upload.array('images', 10), + uploadPropertyImages +); +router.delete( + '/:id/images/:imageId', + authorize('renter', 'admin'), + deletePropertyImage +); + +// Rating route +router.post('/:id/ratings', addPropertyRating); + +module.exports = router; diff --git a/backend/routes/reviews.js b/backend/routes/reviews.js new file mode 100644 index 0000000..d961c19 --- /dev/null +++ b/backend/routes/reviews.js @@ -0,0 +1,34 @@ +const express = require('express'); +const router = express.Router(); +const { protect, authorize } = require('../middleware/auth'); +const { + getReviews, + getReview, + createReview, + updateReview, + deleteReview, + getPropertyReviews, + getUserReviews +} = require('../controllers/reviewController'); + +// Get all reviews for a property +router.get('/property/:propertyId', getPropertyReviews); + +// Get all reviews by a user +router.get('/user/:userId', getUserReviews); + +// Protected routes +router.use(protect); + +router + .route('/') + .get(getReviews) + .post(authorize('tenant', 'admin'), createReview); + +router + .route('/:id') + .get(getReview) + .put(authorize('tenant', 'admin'), updateReview) + .delete(authorize('tenant', 'admin'), deleteReview); + +module.exports = router; diff --git a/backend/routes/settingsRoutes.js b/backend/routes/settingsRoutes.js new file mode 100644 index 0000000..7911faa --- /dev/null +++ b/backend/routes/settingsRoutes.js @@ -0,0 +1,28 @@ +const express = require('express'); +const router = express.Router(); +const { protect } = require('../middleware/auth'); +const { upload } = require('../utils/fileUpload'); +const settingsController = require('../controllers/settingsController'); + +// Get all settings +router.get('/', protect, settingsController.getAllSettings); + +// Profile routes +router.put('/profile', protect, settingsController.updateProfile); +router.put('/profile/image', protect, upload.single('image'), settingsController.updateProfileImage); + +// Security routes +router.put('/security/password', protect, settingsController.updatePassword); +router.put('/security/settings', protect, settingsController.updateSecuritySettings); +router.get('/security/logs', protect, settingsController.getSecurityLogs); + +// Notification routes +router.put('/notifications', protect, settingsController.updateNotificationPreferences); + +// Theme routes +router.put('/theme', protect, settingsController.updateTheme); + +// Language routes +router.put('/language', protect, settingsController.updateLanguage); + +module.exports = router; diff --git a/backend/routes/upload.js b/backend/routes/upload.js new file mode 100644 index 0000000..62039fc --- /dev/null +++ b/backend/routes/upload.js @@ -0,0 +1,27 @@ +const express = require('express'); +const router = express.Router(); +const multer = require('multer'); +const { uploadAvatar } = require('../controllers/uploadController'); +const { protect } = require('../middleware/auth'); + +// Configure multer for memory storage +const storage = multer.memoryStorage(); +const upload = multer({ + storage, + limits: { + fileSize: 5 * 1024 * 1024, // 5MB limit + }, + fileFilter: (req, file, cb) => { + // Allow only images + if (file.mimetype.startsWith('image/')) { + cb(null, true); + } else { + cb(new Error('Please upload an image file'), false); + } + }, +}); + +// Upload routes +router.post('/avatar', protect, upload.single('avatar'), uploadAvatar); + +module.exports = router; diff --git a/backend/routes/users.js b/backend/routes/users.js new file mode 100644 index 0000000..e476e24 --- /dev/null +++ b/backend/routes/users.js @@ -0,0 +1,31 @@ +const express = require('express'); +const router = express.Router(); +const { + getUsers, + getUser, + updateUser, + deleteUser, + updateUserRole +} = require('../controllers/userController'); +const { protect, authorize } = require('../middleware/auth'); + +// All routes are protected and require admin access +router.use(protect); +router.use(authorize('admin', 'super-admin')); + +router + .route('/') + .get(getUsers); + +router + .route('/:id') + .get(getUser) + .put(updateUser) + .delete(deleteUser); + +// Super-admin only route +router + .route('/:id/role') + .patch(authorize('super-admin'), updateUserRole); + +module.exports = router; diff --git a/backend/scripts/generateUsers.js b/backend/scripts/generateUsers.js new file mode 100644 index 0000000..0025517 --- /dev/null +++ b/backend/scripts/generateUsers.js @@ -0,0 +1,172 @@ +require('dotenv').config(); +const mongoose = require('mongoose'); +const bcrypt = require('bcryptjs'); +const fs = require('fs'); +const path = require('path'); +const User = require('../models/User'); + +const users = [ + { + name: 'John Super Admin', + email: 'superadmin@houserental.com', + password: 'SuperAdmin@123', + role: 'super-admin', + nid: 'SA123456789', + address: '123 Admin Street, Admin City', + isVerified: true, + }, + { + name: 'Jane Admin', + email: 'admin@houserental.com', + password: 'Admin@123', + role: 'admin', + nid: 'AD123456789', + address: '456 Admin Lane, Admin City', + isVerified: true, + }, + { + name: 'Bob Renter', + email: 'renter@houserental.com', + password: 'Renter@123', + role: 'renter', + nid: 'RT123456789', + address: '789 Renter Road, Renter City', + isVerified: true, + }, + { + name: 'Alice Smith', + email: 'alice@example.com', + password: 'User@123', + role: 'user', + nid: 'US123456789', + address: '321 User Avenue, User City', + isVerified: true, + }, + { + name: 'Charlie Brown', + email: 'charlie@example.com', + password: 'User@123', + role: 'user', + nid: 'US987654321', + address: '654 User Boulevard, User City', + isVerified: true, + } +]; + +async function generateUsers() { + try { + // Connect to MongoDB + await mongoose.connect(process.env.MONGODB_URI); + console.log('Connected to MongoDB'); + + // Clear existing users + await User.deleteMany({}); + console.log('Cleared existing users'); + + // Create users + const createdUsers = []; + for (const user of users) { + const hashedPassword = await bcrypt.hash(user.password, 10); + const newUser = await User.create({ + ...user, + password: hashedPassword + }); + createdUsers.push({ ...user, _id: newUser._id }); + } + console.log('Created users'); + + // Generate info.txt content + const infoContent = ` +House Rental Platform - User Credentials +====================================== + +🔐 Login Information +------------------- +All users can log in through the same login page at /auth/login +The system will automatically detect the user's role and redirect them accordingly. + +📝 Registration +-------------- +- Only users and renters can register through the public registration page +- Admin accounts can only be created by the Super Admin through the dashboard +- Renter accounts need verification by an admin before they can access the platform + +🔑 Sample Account Credentials +--------------------------- + +Super Admin +---------- +Email: ${users.find(u => u.role === 'super-admin').email} +Password: ${users.find(u => u.role === 'super-admin').password} +Access: Full platform control, can create/manage admin accounts + +Admin +----- +Email: ${users.find(u => u.role === 'admin').email} +Password: ${users.find(u => u.role === 'admin').password} +Access: Platform management, user verification, content moderation + +Renter +------ +Email: ${users.find(u => u.role === 'renter').email} +Password: ${users.find(u => u.role === 'renter').password} +Access: Property listing, booking management, messaging + +Regular Users +------------ +1. Email: ${users.find(u => u.role === 'user' && u.email === 'alice@example.com').email} + Password: ${users.find(u => u.role === 'user' && u.email === 'alice@example.com').password} + +2. Email: ${users.find(u => u.role === 'user' && u.email === 'charlie@example.com').email} + Password: ${users.find(u => u.role === 'user' && u.email === 'charlie@example.com').password} +Access: Property browsing, booking, reviews, messaging + +⚠️ Security Notice +---------------- +Please change these passwords after first login for security purposes. +These are sample accounts for testing purposes only. + +🔒 Role-Based Access Control +------------------------- +1. Super Admin + - Create, manage, and delete admin accounts + - Full platform management + - Access all features + +2. Admin + - Verify renter accounts + - Moderate content and reviews + - Manage user reports + - Platform monitoring + +3. Renter + - List properties + - Manage bookings + - Respond to inquiries + - Update property details + - View booking analytics + +4. User + - Browse properties + - Make bookings + - Leave reviews + - Message renters + - Manage personal bookings + +Generated on: ${new Date().toLocaleString()} +`; + + // Write to info.txt in the root directory + const infoPath = path.join(__dirname, '../../info.txt'); + fs.writeFileSync(infoPath, infoContent); + console.log('Generated info.txt with credentials'); + + console.log('User generation completed successfully'); + process.exit(0); + } catch (error) { + console.error('Error generating users:', error); + process.exit(1); + } +} + +generateUsers(); diff --git a/backend/scripts/seedDatabase.js b/backend/scripts/seedDatabase.js new file mode 100644 index 0000000..1995490 --- /dev/null +++ b/backend/scripts/seedDatabase.js @@ -0,0 +1,716 @@ +require('dotenv').config(); +const mongoose = require('mongoose'); +const bcrypt = require('bcryptjs'); +const fs = require('fs'); +const path = require('path'); +const connectDB = require('../config/db'); +const User = require('../models/User'); +const Property = require('../models/Property'); +const Post = require('../models/Post'); +const Review = require('../models/Review'); +const Message = require('../models/Message'); + +// Sample data for Bangladesh +const users = [ + { + firstName: 'Super', + lastName: 'Admin', + email: 'superadmin@houserental.com', + password: 'SuperAdmin@123', + role: 'super-admin', + phone: '+8801711111111', + nid: '1234567890', + address: 'House 12, Road 5, Block B, Gulshan-1, Dhaka', + isVerified: true, + security: { + twoFactorEnabled: true, + loginNotifications: true, + activityAlerts: true, + lastPasswordChange: new Date(), + logs: [] + }, + notifications: { + email: true, + push: true, + sms: true, + preferences: { + bookings: true, + messages: true, + reviews: true, + system: true + } + }, + settings: { + theme: 'light', + language: 'bn', + timezone: 'Asia/Dhaka' + } + }, + { + firstName: 'Admin', + lastName: 'User', + email: 'admin@houserental.com', + password: 'Admin@123', + role: 'admin', + phone: '+8801722222222', + nid: '2345678901', + address: 'House 45, Road 8, Block C, Banani DOHS, Dhaka', + isVerified: true, + security: { + twoFactorEnabled: true, + loginNotifications: true, + activityAlerts: true, + lastPasswordChange: new Date(), + logs: [] + }, + notifications: { + email: true, + push: true, + sms: true, + preferences: { + bookings: true, + messages: true, + reviews: true, + system: true + } + }, + settings: { + theme: 'system', + language: 'bn', + timezone: 'Asia/Dhaka' + } + }, + { + firstName: 'Property', + lastName: 'Renter', + email: 'renter@houserental.com', + password: 'Renter@123', + role: 'renter', + phone: '+8801733333333', + nid: '3456789012', + address: 'House 78, Road 11, Block F, Gulshan-2, Dhaka', + isVerified: true, + security: { + twoFactorEnabled: false, + loginNotifications: true, + activityAlerts: true, + lastPasswordChange: new Date(), + logs: [] + }, + notifications: { + email: true, + push: true, + sms: true, + preferences: { + bookings: true, + messages: true, + reviews: true, + system: true + } + }, + settings: { + theme: 'dark', + language: 'bn', + timezone: 'Asia/Dhaka' + } + }, + { + firstName: 'Alice', + lastName: 'Rahman', + email: 'alice@example.com', + password: 'User@123', + role: 'user', + phone: '+8801744444444', + nid: '4567890123', + address: 'House 23, Road 12, Block D, Banani, Dhaka', + isVerified: true, + security: { + twoFactorEnabled: false, + loginNotifications: true, + activityAlerts: true, + lastPasswordChange: new Date(), + logs: [] + }, + notifications: { + email: true, + push: true, + sms: true, + preferences: { + bookings: true, + messages: true, + reviews: true, + system: true + } + }, + settings: { + theme: 'system', + language: 'bn', + timezone: 'Asia/Dhaka' + } + }, + { + firstName: 'Charlie', + lastName: 'Ahmed', + email: 'charlie@example.com', + password: 'User@123', + role: 'user', + phone: '+8801755555555', + nid: '5678901234', + address: 'House 56, Road 27, Block G, Dhanmondi, Dhaka', + isVerified: true, + security: { + twoFactorEnabled: false, + loginNotifications: true, + activityAlerts: true, + lastPasswordChange: new Date(), + logs: [] + }, + notifications: { + email: true, + push: true, + sms: true, + preferences: { + bookings: true, + messages: true, + reviews: true, + system: true + } + }, + settings: { + theme: 'light', + language: 'bn', + timezone: 'Asia/Dhaka' + } + } +]; + +// Read users from info.txt +try { + const infoTxt = fs.readFileSync(path.join(__dirname, '../../info.txt'), 'utf8'); + + // Split the file into sections + const sections = infoTxt.split('\n\n'); + const infoUsers = []; + + // Process each section + for (const section of sections) { + // Skip sections that don't contain user credentials + if (!section.includes('Email:') || !section.includes('Password:')) continue; + + // Extract credentials + const emailMatch = section.match(/Email:\s*([^\n]+)/); + const passwordMatch = section.match(/Password:\s*([^\n]+)/); + + if (emailMatch && passwordMatch) { + const email = emailMatch[1].trim(); + const password = passwordMatch[1].trim(); + + // Extract role from the section header or email + let role = 'user'; + if (section.toLowerCase().includes('super admin') || email.includes('superadmin')) { + role = 'super-admin'; + } else if (section.toLowerCase().includes('admin') && !section.toLowerCase().includes('super')) { + role = 'admin'; + } else if (section.toLowerCase().includes('renter')) { + role = 'renter'; + } + + // Generate a unique NID based on role + const nidPrefix = { + 'super-admin': '1', + 'admin': '2', + 'renter': '3', + 'user': '4' + }[role]; + const nid = `${nidPrefix}${'0'.repeat(9-email.length)}${email.length}${Date.now() % 1000000}`; + + // Create user object + const user = { + firstName: email.split('@')[0], + lastName: 'User', + email, + password, + role, + phone: '+8801712345' + nid.slice(-3), // Generate unique phone number + address: `${role.charAt(0).toUpperCase() + role.slice(1)} Address, Dhaka, Bangladesh`, + nid, + isVerified: true, + security: { + twoFactorEnabled: false, + loginNotifications: true, + activityAlerts: true, + lastPasswordChange: new Date(), + logs: [] + }, + notifications: { + email: true, + push: true, + sms: true, + preferences: { + bookings: true, + messages: true, + reviews: true, + system: true + } + }, + settings: { + theme: 'system', + language: 'bn', + timezone: 'Asia/Dhaka' + } + }; + infoUsers.push(user); + } + } + + // Add the parsed users to the users array + if (infoUsers.length > 0) { + users.push(...infoUsers); + console.log(`Parsed ${infoUsers.length} users from info.txt`); + } else { + console.log('No valid users found in info.txt'); + } +} catch (error) { + console.error('Error reading info.txt:', error.message); +} + +const properties = [ + { + title: 'মডার্ন ফ্যামিলি আপার্টমেন্ট - গুলশান', + description: 'সুন্দর 3 বেডরুম আপার্টমেন্ট, গুলশান-2 তে অবস্থিত। সম্পূর্ণ আধুনিক সুযোগ-সুবিধা সহ।', + address: { + street: 'Road 103', + houseNumber: '24', + floor: 4, + division: 'Dhaka', + district: 'Dhaka', + thana: 'Gulshan', + area: 'Gulshan 2', + postCode: '1212', + country: 'Bangladesh' + }, + location: { + type: 'Point', + coordinates: [90.4152, 23.7925] + }, + price: { + amount: 45000, + currency: 'BDT', + negotiable: true, + advancePayment: 90000 + }, + propertyType: 'Apartment', + status: 'available', + features: { + size: { + value: 1800, + unit: 'sqft' + }, + bedrooms: 3, + bathrooms: 3, + balconies: 2, + parking: { + available: true, + type: 'car' + }, + furnished: 'semi-furnished', + utilities: { + electricity: true, + gas: true, + water: true, + internet: true, + maintenance: true + }, + amenities: [ + 'lift', + 'generator', + 'security', + 'cctv', + 'prayer_room' + ] + }, + preferences: { + tenantType: ['family'], + gender: 'any', + maxOccupants: 6, + petsAllowed: false + }, + images: [ + { + url: 'https://images.pexels.com/photos/2462015/pexels-photo-2462015.jpeg', + alt: 'Living Room' + }, + { + url: 'https://images.pexels.com/photos/1457842/pexels-photo-1457842.jpeg', + alt: 'Kitchen' + } + ] + }, + { + title: 'স্টুডেন্ট মেস - মোহাম্মদপুর', + description: 'ছাত্রদের জন্য আদর্শ আবাসন। মোহাম্মদপুর বাস স্ট্যান্ড থেকে ৫ মিনিটের হাঁটার দূরত্বে।', + address: { + street: 'Shahjahan Road', + houseNumber: '45/A', + floor: 2, + division: 'Dhaka', + district: 'Dhaka', + thana: 'Mohammadpur', + area: 'Mohammadpur Housing', + postCode: '1207', + country: 'Bangladesh' + }, + location: { + type: 'Point', + coordinates: [90.3596, 23.7577] + }, + price: { + amount: 5000, + currency: 'BDT', + negotiable: true, + advancePayment: 10000 + }, + propertyType: 'Mess', + status: 'available', + features: { + size: { + value: 120, + unit: 'sqft' + }, + bedrooms: 1, + bathrooms: 1, + balconies: 0, + parking: { + available: true, + type: 'bike' + }, + furnished: 'semi-furnished', + utilities: { + electricity: true, + gas: false, + water: true, + internet: true, + maintenance: false + }, + amenities: [ + 'generator', + 'security' + ] + }, + preferences: { + tenantType: ['student'], + gender: 'male', + maxOccupants: 2, + petsAllowed: false + }, + images: [ + { + url: 'https://images.pexels.com/photos/1454806/pexels-photo-1454806.jpeg', + alt: 'Room View' + } + ] + }, + { + title: 'বাণিজ্যিক অফিস স্পেস - মতিঝিল', + description: 'মতিঝিলের কেন্দ্রস্থলে অবস্থিত প্রিমিয়াম অফিস স্পেস। 24/7 জেনারেটর ও লিফট সুবিধা।', + address: { + street: 'Dilkusha Commercial Area', + houseNumber: '89', + floor: 6, + division: 'Dhaka', + district: 'Dhaka', + thana: 'Motijheel', + area: 'Dilkusha', + postCode: '1000', + country: 'Bangladesh' + }, + location: { + type: 'Point', + coordinates: [90.4195, 23.7283] + }, + price: { + amount: 75000, + currency: 'BDT', + negotiable: true, + advancePayment: 150000 + }, + propertyType: 'Office', + status: 'available', + features: { + size: { + value: 2200, + unit: 'sqft' + }, + bedrooms: 0, + bathrooms: 2, + balconies: 0, + parking: { + available: true, + type: 'both' + }, + furnished: 'fully-furnished', + utilities: { + electricity: true, + gas: false, + water: true, + internet: true, + maintenance: true + }, + amenities: [ + 'lift', + 'generator', + 'security', + 'cctv', + 'intercom' + ] + }, + preferences: { + tenantType: ['office'], + gender: 'any', + maxOccupants: 30, + petsAllowed: false + }, + images: [ + { + url: 'https://images.pexels.com/photos/1743555/pexels-photo-1743555.jpeg', + alt: 'Office Space' + } + ] + } +]; + +const posts = [ + { + title: 'Beautiful Apartment in Gulshan', + description: 'Spacious 3-bedroom apartment with modern amenities in Gulshan-2. Perfect for families.', + location: 'Gulshan-2, Dhaka', + price: 45000, + approved: true + }, + { + title: 'Studio Apartment in Banani', + description: 'Cozy studio apartment with full furnishing in Banani. Ideal for bachelors or small families.', + location: 'Banani, Dhaka', + price: 25000, + approved: true + }, + { + title: 'Family House in Dhanmondi', + description: 'Spacious 4-bedroom house with garden in Dhanmondi residential area. Perfect for large families.', + location: 'Dhanmondi, Dhaka', + price: 65000, + approved: true + } +]; + +const reviews = [ + { + rating: 5, + comment: 'Beautiful property with excellent amenities. The location is perfect and the owner is very cooperative.', + status: 'approved', + images: [ + 'https://example.com/review1-image1.jpg', + 'https://example.com/review1-image2.jpg' + ] + }, + { + rating: 4, + comment: 'Great property in a prime location. Minor maintenance issues but overall a good experience.', + status: 'approved', + images: [ + 'https://example.com/review2-image1.jpg' + ] + }, + { + rating: 5, + comment: 'Excellent property with modern amenities. The security system is top-notch.', + status: 'approved', + images: [ + 'https://example.com/review3-image1.jpg', + 'https://example.com/review3-image2.jpg' + ] + } +]; + +const messages = [ + { + content: 'Hi, I am interested in your property. Is it still available?', + type: 'text', + formatting: { + bold: [], + italic: [], + code: [], + link: [] + } + }, + { + content: 'Yes, the property is available. Would you like to schedule a viewing?', + type: 'text', + formatting: { + bold: [], + italic: [], + code: [], + link: [] + } + }, + { + content: 'Great! When would be a good time to visit?', + type: 'text', + formatting: { + bold: [], + italic: [], + code: [], + link: [] + } + } +]; + +// Seed Database Function +const seedDatabase = async () => { + try { + console.log('Attempting to connect to MongoDB at:', process.env.MONGODB_URI); + await connectDB(); + console.log('Connected to MongoDB successfully'); + + // Clear existing data + console.log('Clearing existing data...'); + await User.deleteMany({}); + await Property.deleteMany({}); + await Post.deleteMany({}); + await Review.deleteMany({}); + await Message.deleteMany({}); + console.log('Existing data cleared'); + + // Create users + console.log('Creating users...'); + const hashedUsers = await Promise.all(users.map(async user => ({ + ...user, + password: await bcrypt.hash(user.password, 10) + }))); + + // Create users one by one to handle duplicates + for (const user of hashedUsers) { + try { + await User.create(user); + } catch (error) { + if (error.code === 11000) { + console.log(`Skipping duplicate user: ${user.email}`); + continue; + } + throw error; + } + } + console.log(`Created ${hashedUsers.length} users`); + + // Create properties + console.log('Creating properties...'); + const createdProperties = []; + try { + for (const property of properties) { + const newProperty = await Property.create({ + ...property, + owner: (await User.findOne({ role: 'super-admin' }))._id + }); + createdProperties.push(newProperty); + } + console.log(`Created ${createdProperties.length} properties`); + } catch (error) { + throw new Error(`Error creating properties: ${error.message}`); + } + + // Create posts + console.log('Creating posts...'); + try { + for (const post of posts) { + await Post.create({ + ...post, + ownerId: (await User.findOne({ role: 'super-admin' }))._id + }); + } + console.log(`Created ${posts.length} posts`); + } catch (error) { + throw new Error(`Error creating posts: ${error.message}`); + } + + // Create reviews + console.log('Creating reviews...'); + try { + const usedCombinations = new Set(); // Track user-property combinations + for (const review of reviews) { + let property, reviewer, combinationKey; + + // Keep trying until we find a unique user-property combination + do { + property = createdProperties[Math.floor(Math.random() * createdProperties.length)]; + do { + reviewer = await User.findOne({ role: 'user' }); + } while (reviewer._id.equals(property.owner)); + + combinationKey = `${property._id}-${reviewer._id}`; + } while (usedCombinations.has(combinationKey)); + + usedCombinations.add(combinationKey); + + await Review.create({ + ...review, + propertyId: property._id, + userId: reviewer._id + }); + } + console.log(`Created ${reviews.length} reviews`); + } catch (error) { + throw new Error(`Error creating reviews: ${error.message}`); + } + + // Create messages + console.log('Creating messages...'); + try { + const usedCombinations = new Set(); // Track sender-recipient combinations + for (const message of messages) { + let property, sender, recipient, combinationKey; + + // Keep trying until we find a unique sender-recipient combination + do { + property = createdProperties[Math.floor(Math.random() * createdProperties.length)]; + do { + sender = await User.findOne({ role: 'user' }); + do { + recipient = await User.findOne({ role: 'user' }); + } while (recipient._id.equals(sender._id)); + } while (sender._id.equals(property.owner) && recipient._id.equals(property.owner)); + + combinationKey = `${sender._id}-${recipient._id}`; + } while (usedCombinations.has(combinationKey)); + + usedCombinations.add(combinationKey); + + // Create chatId by sorting and concatenating user IDs + const chatId = [sender._id, recipient._id].sort().join('_'); + + await Message.create({ + ...message, + sender: sender._id, + recipient: recipient._id, + propertyId: property._id, + chatId + }); + } + console.log(`Created ${messages.length} messages`); + } catch (error) { + throw new Error(`Error creating messages: ${error.message}`); + } + + console.log('Database seeded successfully! 🌱'); + await mongoose.connection.close(); + process.exit(0); + } catch (error) { + console.error('Error seeding database:', error.message); + if (mongoose.connection.readyState === 1) { + await mongoose.connection.close(); + } + process.exit(1); + } +} + +// Run the seeding function +seedDatabase(); diff --git a/backend/scripts/verifyUsers.js b/backend/scripts/verifyUsers.js new file mode 100644 index 0000000..dddba36 --- /dev/null +++ b/backend/scripts/verifyUsers.js @@ -0,0 +1,81 @@ +require('dotenv').config(); +const mongoose = require('mongoose'); +const User = require('../models/User'); + +const usersToCheck = [ + { + email: 'superadmin@houserental.com', + role: 'superadmin' + }, + { + email: 'admin@houserental.com', + role: 'admin' + }, + { + email: 'renter@houserental.com', + role: 'renter' + }, + { + email: 'alice@example.com', + role: 'user' + }, + { + email: 'charlie@example.com', + role: 'user' + } +]; + +async function verifyUsers() { + try { + // Connect to MongoDB + await mongoose.connect(process.env.MONGODB_URI || 'mongodb://localhost:27017/house-rental', { + useNewUrlParser: true, + useUnifiedTopology: true, + }); + console.log('Connected to MongoDB successfully'); + + console.log('\nVerifying users from info.txt...\n'); + console.log('----------------------------------------'); + + for (const userToCheck of usersToCheck) { + const user = await User.findOne({ email: userToCheck.email }); + + if (user) { + console.log(`✅ Found user: ${userToCheck.email}`); + console.log(` Role: ${user.role} (Expected: ${userToCheck.role})`); + console.log(` Status: ${user.status}`); + } else { + console.log(`❌ Missing user: ${userToCheck.email}`); + console.log(` Expected role: ${userToCheck.role}`); + } + console.log('----------------------------------------'); + } + + // Check total number of users + const totalUsers = await User.countDocuments(); + console.log(`\nTotal users in database: ${totalUsers}`); + + // Get all users for detailed report + const allUsers = await User.find({}, 'email role status'); + console.log('\nAll users in database:'); + console.log('----------------------------------------'); + allUsers.forEach(user => { + console.log(`Email: ${user.email}`); + console.log(`Role: ${user.role}`); + console.log(`Status: ${user.status}`); + console.log('----------------------------------------'); + }); + + await mongoose.connection.close(); + console.log('\nDatabase connection closed'); + } catch (error) { + console.error('Error:', error.message); + if (mongoose.connection.readyState === 1) { + await mongoose.connection.close(); + } + process.exit(1); + } +} + +// Run the verification +verifyUsers(); diff --git a/backend/seedData.js b/backend/seedData.js new file mode 100644 index 0000000..f6fc513 --- /dev/null +++ b/backend/seedData.js @@ -0,0 +1,241 @@ +require('dotenv').config(); +const mongoose = require('mongoose'); +const bcrypt = require('bcryptjs'); +const User = require('./models/User'); +const Property = require('./models/Property'); + +const users = [ + { + name: 'Super Admin', + email: 'superadmin@houserental.com', + password: 'SuperAdmin@123', + role: 'super-admin', + isVerified: true, + nid: 'SA123456789', + address: 'Dhaka, Bangladesh', + phone: '+8801700000001' + }, + { + name: 'Admin User', + email: 'admin@houserental.com', + password: 'Admin@123', + role: 'admin', + isVerified: true, + nid: 'AD123456789', + address: 'Dhaka, Bangladesh', + phone: '+8801700000002' + }, + { + name: 'Property Renter', + email: 'renter@houserental.com', + password: 'Renter@123', + role: 'renter', + isVerified: true, + nid: 'RE123456789', + address: 'Chittagong, Bangladesh', + phone: '+8801700000003' + }, + { + name: 'Alice Cooper', + email: 'alice@example.com', + password: 'User@123', + role: 'user', + isVerified: true, + nid: 'US123456789', + address: 'Sylhet, Bangladesh', + phone: '+8801700000004' + }, + { + name: 'Charlie Brown', + email: 'charlie@example.com', + password: 'User@123', + role: 'user', + isVerified: true, + nid: 'US987654321', + address: 'Khulna, Bangladesh', + phone: '+8801700000005' + }, +]; + +const properties = [ + { + title: 'Modern Apartment in Gulshan', + description: 'Luxurious 3-bedroom apartment with modern amenities and stunning city views. Perfect for families or professionals.', + address: { + street: 'Road 103, House 7', + area: 'Gulshan', + city: 'Dhaka', + state: 'Dhaka', + zipCode: '1212', + country: 'Bangladesh' + }, + location: { + type: 'Point', + coordinates: [90.415482, 23.793445] // Longitude, Latitude for Gulshan + }, + price: 35000, + propertyType: 'Apartment', + status: 'available', + features: { + bedrooms: 3, + bathrooms: 2, + size: 1800, + furnished: true, + parking: true, + yearBuilt: 2020 + }, + amenities: [ + 'Air Conditioning', + 'Internet', + 'Cable TV', + 'Security', + 'Generator', + 'Elevator', + 'CCTV' + ], + images: [ + { + url: 'https://images.unsplash.com/photo-1522708323590-d24dbb6b0267', + alt: 'Modern living room with city view' + }, + { + url: 'https://images.unsplash.com/photo-1502672260266-1c1ef2d93688', + alt: 'Spacious bedroom with natural light' + } + ], + virtualTour: 'https://my.matterport.com/show/?m=example', + }, + { + title: 'Spacious Family House in Dhanmondi', + description: 'Beautiful family house with garden and modern amenities. Located in a quiet neighborhood.', + address: { + street: 'Road 27, House 15', + area: 'Dhanmondi', + city: 'Dhaka', + state: 'Dhaka', + zipCode: '1209', + country: 'Bangladesh' + }, + location: { + type: 'Point', + coordinates: [90.373613, 23.755251] // Longitude, Latitude for Dhanmondi + }, + price: 45000, + propertyType: 'House', + status: 'available', + features: { + bedrooms: 4, + bathrooms: 3, + size: 2500, + furnished: true, + parking: true, + yearBuilt: 2019 + }, + amenities: [ + 'Air Conditioning', + 'Internet', + 'Cable TV', + 'Security', + 'Generator', + 'CCTV', + 'Water Reserve' + ], + images: [ + { + url: 'https://images.unsplash.com/photo-1580587771525-78b9dba3b914', + alt: 'House front view with garden' + }, + { + url: 'https://images.unsplash.com/photo-1576941089067-2de3c901e126', + alt: 'Modern kitchen with island' + } + ] + }, + { + title: 'Cozy Studio in Banani', + description: 'Modern studio apartment perfect for singles or couples. Prime location with great amenities.', + address: { + street: 'Road 11, House 67', + area: 'Banani', + city: 'Dhaka', + state: 'Dhaka', + zipCode: '1213', + country: 'Bangladesh' + }, + location: { + type: 'Point', + coordinates: [90.406198, 23.793883] // Longitude, Latitude for Banani + }, + price: 20000, + propertyType: 'Studio', + status: 'available', + features: { + bedrooms: 1, + bathrooms: 1, + size: 800, + furnished: true, + parking: false, + yearBuilt: 2021 + }, + amenities: [ + 'Air Conditioning', + 'Internet', + 'Cable TV', + 'Security', + 'Elevator' + ], + images: [ + { + url: 'https://images.unsplash.com/photo-1536376072261-38c75010e6c9', + alt: 'Modern studio interior' + }, + { + url: 'https://images.unsplash.com/photo-1554995207-c18c203602cb', + alt: 'Compact kitchen space' + } + ] + } +]; + +const seedDatabase = async () => { + try { + // Connect to MongoDB + await mongoose.connect(process.env.MONGODB_URI); + console.log('Connected to MongoDB'); + + // Clear existing data + await User.deleteMany(); + await Property.deleteMany(); + console.log('Existing data cleared'); + + // Create users + const createdUsers = []; + for (const user of users) { + const hashedPassword = await bcrypt.hash(user.password, 10); + const newUser = await User.create({ + ...user, + password: hashedPassword + }); + createdUsers.push(newUser); + console.log(`Created user: ${user.email}`); + } + + // Create properties and assign to renter + const renter = createdUsers.find(user => user.role === 'renter'); + for (const property of properties) { + await Property.create({ + ...property, + owner: renter._id + }); + console.log(`Created property: ${property.title}`); + } + + console.log('Database seeded successfully'); + process.exit(0); + } catch (error) { + console.error('Error seeding database:', error); + process.exit(1); + } +}; + +seedDatabase(); diff --git a/backend/server.js b/backend/server.js new file mode 100644 index 0000000..26b84af --- /dev/null +++ b/backend/server.js @@ -0,0 +1,104 @@ +require('dotenv').config(); +const express = require('express'); +const cors = require('cors'); +const morgan = require('morgan'); +const path = require('path'); +const helmet = require('helmet'); +const xss = require('xss-clean'); +const rateLimit = require('express-rate-limit'); +const mongoSanitize = require('express-mongo-sanitize'); +const connectDB = require('./config/db'); +const { + errorHandler, + notFound, + handleUncaughtExceptions, + handleUnhandledRejections, +} = require('./middleware/errorMiddleware'); + +// Handle uncaught exceptions +handleUncaughtExceptions(); + +const app = express(); + +// Connect to MongoDB +connectDB(); + +// Security middleware +app.use(helmet()); // Set security headers +app.use(xss()); // Prevent XSS attacks +app.use(mongoSanitize()); // Prevent NoSQL injections + +// Rate limiting +const limiter = rateLimit({ + windowMs: 10 * 60 * 1000, // 10 minutes + max: 100 // limit each IP to 100 requests per windowMs +}); +app.use('/api', limiter); + +// Regular middleware +app.use(cors({ + origin: ['http://localhost:3000', 'http://127.0.0.1:3000'], + credentials: true, + methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization'] +})); +app.use(express.json({ limit: '10mb' })); +app.use(express.urlencoded({ extended: true, limit: '10mb' })); +app.use(morgan('dev')); + +// Serve static files +app.use('/uploads', express.static(path.join(__dirname, 'public', 'uploads'))); +app.use('/uploads/profiles', express.static(path.join(__dirname, 'uploads', 'profiles'))); + +// Import routes +const authRoutes = require('./routes/auth'); +const propertyRoutes = require('./routes/properties'); +const userRoutes = require('./routes/users'); +const chatbotRoutes = require('./routes/chatbot'); +const reviewRoutes = require('./routes/reviews'); +const bookingRoutes = require('./routes/bookings'); +const uploadRoutes = require('./routes/upload'); +const notificationRoutes = require('./routes/notificationRoutes'); +const settingsRoutes = require('./routes/settingsRoutes'); + +// Import services +const notificationService = require('./services/notificationService'); +const securityService = require('./services/securityService'); + +// Mount routes +app.use('/api/auth', authRoutes); +app.use('/api/properties', propertyRoutes); +app.use('/api/users', userRoutes); +app.use('/api/chatbot', chatbotRoutes); +app.use('/api/reviews', reviewRoutes); +app.use('/api/bookings', bookingRoutes); +app.use('/api/upload', uploadRoutes); +app.use('/api/notifications', notificationRoutes); +app.use('/api/settings', settingsRoutes); + +// Base route +app.get('/api/', (req, res) => { + res.json({ + message: 'Welcome to House Rental API', + version: '1.0.0', + environment: process.env.NODE_ENV + }); +}); + +// Error handling +app.use(notFound); +app.use(errorHandler); + +const PORT = process.env.PORT || 5000; +const server = app.listen(PORT, () => { + console.log(`Server running on port ${PORT}`); +}); + +// Initialize WebSocket notification service +notificationService.initialize(server); + +// Initialize security service +securityService.initialize(); + +// Handle unhandled promise rejections +handleUnhandledRejections(server); diff --git a/backend/services/chatService.js b/backend/services/chatService.js new file mode 100644 index 0000000..661c4d3 --- /dev/null +++ b/backend/services/chatService.js @@ -0,0 +1,286 @@ +const AWS = require('aws-sdk'); +const sharp = require('sharp'); +const axios = require('axios'); +const cheerio = require('cheerio'); +const { Readable } = require('stream'); +const Message = require('../models/Message'); +const { getAudioDurationInSeconds } = require('get-audio-duration'); + +class ChatService { + constructor() { + this.s3 = new AWS.S3({ + accessKeyId: process.env.AWS_ACCESS_KEY_ID, + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, + region: process.env.AWS_REGION, + }); + } + + // File upload handlers + async uploadFile(file, type) { + const key = `uploads/${type}/${Date.now()}-${file.originalname}`; + const params = { + Bucket: process.env.AWS_S3_BUCKET, + Key: key, + Body: file.buffer, + ContentType: file.mimetype, + ACL: 'public-read', + }; + + const result = await this.s3.upload(params).promise(); + return { + url: result.Location, + key: result.Key, + }; + } + + async processImage(file) { + // Generate thumbnail + const thumbnail = await sharp(file.buffer) + .resize(300, 300, { + fit: 'inside', + withoutEnlargement: true, + }) + .jpeg({ quality: 80 }) + .toBuffer(); + + // Upload original + const original = await this.uploadFile(file, 'images'); + + // Upload thumbnail + const thumbnailFile = { + buffer: thumbnail, + originalname: `thumb-${file.originalname}`, + mimetype: 'image/jpeg', + }; + const thumbnailResult = await this.uploadFile(thumbnailFile, 'thumbnails'); + + return { + type: 'image', + url: original.url, + thumbnail: thumbnailResult.url, + filename: file.originalname, + filesize: file.size, + mimeType: file.mimetype, + }; + } + + async processVoiceMessage(file) { + // Get audio duration + const duration = await getAudioDurationInSeconds(Readable.from(file.buffer)); + + // Upload audio file + const result = await this.uploadFile(file, 'voice'); + + return { + type: 'voice', + url: result.url, + filename: file.originalname, + filesize: file.size, + mimeType: file.mimetype, + duration: Math.round(duration), + }; + } + + async processFile(file) { + const result = await this.uploadFile(file, 'files'); + + return { + type: 'file', + url: result.url, + filename: file.originalname, + filesize: file.size, + mimeType: file.mimetype, + }; + } + + // Link preview + async getLinkPreview(url) { + try { + const response = await axios.get(url); + const $ = cheerio.load(response.data); + + const metadata = { + title: $('meta[property="og:title"]').attr('content') || $('title').text(), + description: $('meta[property="og:description"]').attr('content') || $('meta[name="description"]').attr('content'), + image: $('meta[property="og:image"]').attr('content'), + siteName: $('meta[property="og:site_name"]').attr('content'), + }; + + return { + type: 'link', + url, + metadata, + }; + } catch (error) { + console.error('Error generating link preview:', error); + return { + type: 'link', + url, + metadata: { + title: url, + }, + }; + } + } + + // Message formatting + parseMessageFormatting(content) { + const formatting = { + bold: [], + italic: [], + code: [], + link: [], + }; + + // Bold: **text** + let match; + const boldRegex = /\*\*(.*?)\*\*/g; + while ((match = boldRegex.exec(content)) !== null) { + formatting.bold.push({ + start: match.index, + end: match.index + match[1].length, + }); + } + + // Italic: *text* + const italicRegex = /(? { + acc[msg.type] = (acc[msg.type] || 0) + 1; + return acc; + }, {}), + attachmentCount: messages.reduce((acc, msg) => acc + (msg.attachments?.length || 0), 0), + reactionCount: messages.reduce((acc, msg) => acc + (msg.reactions?.length || 0), 0), + averageResponseTime: this.calculateAverageResponseTime(messages), + mostActiveHours: this.getMostActiveHours(messages), + participantStats: this.getParticipantStats(messages), + }; + } + + calculateAverageResponseTime(messages) { + let totalTime = 0; + let count = 0; + + for (let i = 1; i < messages.length; i++) { + if (messages[i].sender !== messages[i - 1].sender) { + const time = messages[i].createdAt - messages[i - 1].createdAt; + if (time < 3600000) { // Only count if response is within 1 hour + totalTime += time; + count++; + } + } + } + + return count > 0 ? Math.round(totalTime / count) : 0; + } + + getMostActiveHours(messages) { + const hourCounts = new Array(24).fill(0); + + messages.forEach(msg => { + const hour = new Date(msg.createdAt).getHours(); + hourCounts[hour]++; + }); + + return hourCounts; + } + + getParticipantStats(messages) { + const stats = {}; + + messages.forEach(msg => { + if (!stats[msg.sender]) { + stats[msg.sender] = { + messageCount: 0, + attachments: 0, + reactions: 0, + charactersTyped: 0, + }; + } + + stats[msg.sender].messageCount++; + stats[msg.sender].attachments += msg.attachments?.length || 0; + stats[msg.sender].reactions += msg.reactions?.length || 0; + stats[msg.sender].charactersTyped += msg.content?.length || 0; + }); + + return stats; + } + + // Message backup + async exportChatHistory(chatId, format = 'json') { + const messages = await Message.find({ chatId }) + .sort({ createdAt: 1 }) + .populate('sender', 'name') + .populate('recipient', 'name'); + + if (format === 'json') { + return JSON.stringify(messages, null, 2); + } + + if (format === 'csv') { + const csv = [ + 'Date,Sender,Message,Type,Attachments,Reactions', + ...messages.map(msg => [ + msg.createdAt, + msg.sender.name, + msg.content?.replace(/"/g, '""') || '', + msg.type, + msg.attachments?.length || 0, + msg.reactions?.length || 0, + ].join(',')) + ].join('\n'); + + return csv; + } + + throw new Error('Unsupported export format'); + } +} + +module.exports = new ChatService(); diff --git a/backend/services/chatbotService.js b/backend/services/chatbotService.js new file mode 100644 index 0000000..268e0e7 --- /dev/null +++ b/backend/services/chatbotService.js @@ -0,0 +1,297 @@ +const dialogflow = require('@google-cloud/dialogflow'); +const { v4: uuidv4 } = require('uuid'); +const Message = require('../models/Message'); +const Settings = require('../models/Settings'); + +class ChatbotService { + constructor() { + // Initialize Dialogflow client with credentials + this.projectId = process.env.DIALOGFLOW_PROJECT_ID; + this.sessionClient = new dialogflow.SessionsClient({ + keyFilename: process.env.GOOGLE_APPLICATION_CREDENTIALS, + }); + } + + // Process user message and get chatbot response + async processMessage(message, userId, context = {}) { + try { + // Create a unique session ID for this conversation + const sessionId = `${userId}-${uuidv4()}`; + const sessionPath = this.sessionClient.projectAgentSessionPath( + this.projectId, + sessionId + ); + + // Create the text request + const request = { + session: sessionPath, + queryInput: { + text: { + text: message, + languageCode: 'en-US', + }, + }, + queryParams: { + contexts: this._formatContexts(context), + }, + }; + + // Send request to Dialogflow + const responses = await this.sessionClient.detectIntent(request); + const result = responses[0].queryResult; + + // Save the interaction for training + await this._saveInteraction(userId, message, result); + + return this._formatResponse(result); + } catch (error) { + console.error('Error processing message:', error); + throw error; + } + } + + // Format contexts for Dialogflow + _formatContexts(context) { + return Object.entries(context).map(([name, params]) => ({ + name: `projects/${this.projectId}/agent/sessions/-/contexts/${name}`, + lifespanCount: 5, + parameters: params, + })); + } + + // Format response from Dialogflow + _formatResponse(result) { + return { + text: result.fulfillmentText, + intent: result.intent.displayName, + confidence: result.intentDetectionConfidence, + parameters: result.parameters, + contexts: result.outputContexts, + action: result.action, + source: 'dialogflow', + }; + } + + // Save interaction for training + async _saveInteraction(userId, userMessage, result) { + try { + const interaction = { + userId, + userMessage, + botResponse: result.fulfillmentText, + intent: result.intent.displayName, + confidence: result.intentDetectionConfidence, + timestamp: new Date(), + successful: result.intentDetectionConfidence > 0.7, + }; + + // Save to database for training + await ChatbotInteraction.create(interaction); + } catch (error) { + console.error('Error saving interaction:', error); + } + } + + // Train chatbot with new examples + async trainWithExamples(examples) { + try { + const intentsClient = new dialogflow.IntentsClient({ + keyFilename: process.env.GOOGLE_APPLICATION_CREDENTIALS, + }); + + for (const example of examples) { + const intent = { + displayName: example.intent, + trainingPhrases: example.phrases.map(phrase => ({ + type: 'EXAMPLE', + parts: [{ text: phrase }], + })), + messages: [ + { + text: { + text: [example.response], + }, + }, + ], + }; + + await intentsClient.createIntent({ + parent: `projects/${this.projectId}/agent`, + intent, + }); + } + + return { success: true, message: 'Training examples added successfully' }; + } catch (error) { + console.error('Error training chatbot:', error); + throw error; + } + } + + // Get chatbot analytics + async getAnalytics(startDate, endDate) { + try { + const pipeline = [ + { + $match: { + timestamp: { + $gte: new Date(startDate), + $lte: new Date(endDate), + }, + }, + }, + { + $group: { + _id: { + intent: '$intent', + successful: '$successful', + }, + count: { $sum: 1 }, + avgConfidence: { $avg: '$confidence' }, + }, + }, + { + $group: { + _id: '$_id.intent', + total: { $sum: '$count' }, + successful: { + $sum: { + $cond: [{ $eq: ['$_id.successful', true] }, '$count', 0], + }, + }, + failed: { + $sum: { + $cond: [{ $eq: ['$_id.successful', false] }, '$count', 0], + }, + }, + avgConfidence: { $avg: '$avgConfidence' }, + }, + }, + ]; + + const results = await ChatbotInteraction.aggregate(pipeline); + + // Calculate overall statistics + const totalInteractions = results.reduce((sum, r) => sum + r.total, 0); + const successfulInteractions = results.reduce((sum, r) => sum + r.successful, 0); + + return { + intents: results, + summary: { + totalInteractions, + successRate: (successfulInteractions / totalInteractions) * 100, + uniqueIntents: results.length, + }, + }; + } catch (error) { + console.error('Error getting chatbot analytics:', error); + throw error; + } + } + + // Get suggested improvements + async getSuggestedImprovements() { + try { + // Find intents with low confidence + const lowConfidenceIntents = await ChatbotInteraction.aggregate([ + { + $group: { + _id: '$intent', + avgConfidence: { $avg: '$confidence' }, + count: { $sum: 1 }, + failedCount: { + $sum: { + $cond: [{ $lt: ['$confidence', 0.7] }, 1, 0], + }, + }, + }, + }, + { + $match: { + $or: [ + { avgConfidence: { $lt: 0.8 } }, + { failedCount: { $gt: 10 } }, + ], + }, + }, + ]); + + // Find common user messages that failed + const failedMessages = await ChatbotInteraction.aggregate([ + { + $match: { + confidence: { $lt: 0.7 }, + }, + }, + { + $group: { + _id: '$userMessage', + count: { $sum: 1 }, + }, + }, + { + $sort: { count: -1 }, + }, + { + $limit: 10, + }, + ]); + + return { + lowConfidenceIntents, + failedMessages, + suggestions: [ + ...lowConfidenceIntents.map(intent => ({ + type: 'intent', + message: `Intent "${intent._id}" has low confidence (${Math.round(intent.avgConfidence * 100)}%). Consider adding more training examples.`, + priority: intent.failedCount > 20 ? 'high' : 'medium', + })), + ...failedMessages.map(msg => ({ + type: 'message', + message: `Common failed message: "${msg._id}" (${msg.count} times). Consider creating a new intent for this.`, + priority: msg.count > 5 ? 'high' : 'medium', + })), + ], + }; + } catch (error) { + console.error('Error getting improvement suggestions:', error); + throw error; + } + } + + // Handle fallback responses + async handleFallback(userMessage, userId) { + try { + // Save the fallback interaction + await ChatbotInteraction.create({ + userId, + userMessage, + botResponse: 'Fallback triggered', + intent: 'fallback', + confidence: 0, + timestamp: new Date(), + successful: false, + }); + + // Get a human admin if available + const settings = await Settings.getInstance(); + if (settings.chatbot.enableLiveHandoff) { + // TODO: Implement live handoff to admin + return { + text: "I'm not sure how to help with that. Let me connect you with a human agent.", + action: 'handoff', + }; + } + + return { + text: "I apologize, but I'm not sure how to help with that. Would you like to try rephrasing your question or speak with a human agent?", + action: 'suggest_rephrase', + }; + } catch (error) { + console.error('Error handling fallback:', error); + throw error; + } + } +} + +module.exports = new ChatbotService(); diff --git a/backend/services/notificationService.js b/backend/services/notificationService.js new file mode 100644 index 0000000..b04e6d9 --- /dev/null +++ b/backend/services/notificationService.js @@ -0,0 +1,190 @@ +const WebSocket = require('ws'); +const jwt = require('jsonwebtoken'); +const Notification = require('../models/Notification'); +const User = require('../models/User'); + +class NotificationService { + constructor() { + this.clients = new Map(); // userId -> WebSocket + this.pendingNotifications = new Map(); // userId -> Notification[] + } + + initialize(server) { + this.wss = new WebSocket.Server({ server, path: '/ws' }); + + this.wss.on('connection', async (ws, req) => { + try { + // Extract token from query string + const token = new URL(req.url, 'http://localhost').searchParams.get('token'); + if (!token) { + ws.close(4001, 'Authentication required'); + return; + } + + // Verify token + const decoded = jwt.verify(token, process.env.JWT_SECRET); + const userId = decoded.id; + + // Store the connection + this.clients.set(userId, ws); + + // Send any pending notifications + await this.sendPendingNotifications(userId); + + // Handle client messages + ws.on('message', async (message) => { + try { + const data = JSON.parse(message); + switch (data.type) { + case 'READ_NOTIFICATION': + await this.handleReadNotification(userId, data.notificationId); + break; + case 'CLEAR_NOTIFICATIONS': + await this.handleClearNotifications(userId); + break; + case 'PING': + ws.send(JSON.stringify({ type: 'PONG' })); + break; + } + } catch (error) { + console.error('Error handling WebSocket message:', error); + } + }); + + // Handle client disconnect + ws.on('close', () => { + this.clients.delete(userId); + }); + + // Send initial connection success + ws.send(JSON.stringify({ + type: 'CONNECTED', + message: 'Successfully connected to notification service' + })); + + } catch (error) { + console.error('WebSocket connection error:', error); + ws.close(4002, 'Authentication failed'); + } + }); + } + + async createNotification(data) { + try { + // Create notification in database + const notification = await Notification.createNotification(data); + if (!notification) return null; // User has disabled this type of notification + + // Get user's preferences + const user = await User.findById(data.userId) + .select('preferences.notifications'); + + // Check if user is connected via WebSocket + const ws = this.clients.get(data.userId); + if (ws && ws.readyState === WebSocket.OPEN) { + // Send notification immediately + ws.send(JSON.stringify({ + type: 'NEW_NOTIFICATION', + notification + })); + } else { + // Store notification for later delivery + if (!this.pendingNotifications.has(data.userId)) { + this.pendingNotifications.set(data.userId, []); + } + this.pendingNotifications.get(data.userId).push(notification); + } + + return notification; + } catch (error) { + console.error('Error creating notification:', error); + return null; + } + } + + async sendPendingNotifications(userId) { + const ws = this.clients.get(userId); + if (!ws || ws.readyState !== WebSocket.OPEN) return; + + const pending = this.pendingNotifications.get(userId) || []; + if (pending.length === 0) return; + + // Send all pending notifications + for (const notification of pending) { + ws.send(JSON.stringify({ + type: 'NEW_NOTIFICATION', + notification + })); + } + + // Clear pending notifications + this.pendingNotifications.delete(userId); + } + + async handleReadNotification(userId, notificationId) { + try { + const notification = await Notification.findOne({ + _id: notificationId, + userId + }); + + if (notification) { + await notification.markAsRead(); + } + } catch (error) { + console.error('Error marking notification as read:', error); + } + } + + async handleClearNotifications(userId) { + try { + await Notification.deleteMany({ userId }); + } catch (error) { + console.error('Error clearing notifications:', error); + } + } + + broadcastSystemNotification(message, excludeUsers = []) { + this.clients.forEach((ws, userId) => { + if (excludeUsers.includes(userId)) return; + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ + type: 'SYSTEM_NOTIFICATION', + message + })); + } + }); + } + + // Helper method to create different types of notifications + async sendNotification(type, userId, data = {}) { + const notificationTypes = Notification.TYPES; + const template = notificationTypes[type]; + + if (!template) { + throw new Error(`Invalid notification type: ${type}`); + } + + return this.createNotification({ + userId, + ...template, + ...data + }); + } + + // Utility method to check if a user is connected + isUserConnected(userId) { + const ws = this.clients.get(userId); + return ws && ws.readyState === WebSocket.OPEN; + } + + // Get connected users count + getConnectedUsersCount() { + return this.clients.size; + } +} + +// Create singleton instance +const notificationService = new NotificationService(); + +module.exports = notificationService; diff --git a/backend/services/securityService.js b/backend/services/securityService.js new file mode 100644 index 0000000..a1bf34e --- /dev/null +++ b/backend/services/securityService.js @@ -0,0 +1,112 @@ +const { getClientInfo } = require('../utils/security'); +const User = require('../models/User'); + +class SecurityService { + constructor() { + this.loginAttempts = new Map(); + this.blockedIPs = new Set(); + } + + initialize() { + // Clear login attempts every hour + setInterval(() => { + this.loginAttempts.clear(); + }, 60 * 60 * 1000); + } + + async logSecurityEvent(userId, eventType, req, details = {}) { + try { + const clientInfo = getClientInfo(req); + const securityLog = { + eventType, + timestamp: new Date(), + ip: clientInfo.ip, + device: clientInfo.device, + location: clientInfo.location, + ...details + }; + + await User.findByIdAndUpdate(userId, { + $push: { + 'security.logs': { + $each: [securityLog], + $position: 0, + $slice: 50 // Keep only last 50 logs + } + } + }); + + return true; + } catch (error) { + console.error('Error logging security event:', error); + return false; + } + } + + async recordLoginAttempt(ip, success) { + if (!this.loginAttempts.has(ip)) { + this.loginAttempts.set(ip, { count: 0, firstAttempt: Date.now() }); + } + + const attempt = this.loginAttempts.get(ip); + attempt.count++; + + if (!success && attempt.count >= 5) { + // Block IP if 5 failed attempts within 15 minutes + const timeDiff = Date.now() - attempt.firstAttempt; + if (timeDiff <= 15 * 60 * 1000) { + this.blockedIPs.add(ip); + // Remove IP from blocked list after 1 hour + setTimeout(() => { + this.blockedIPs.delete(ip); + this.loginAttempts.delete(ip); + }, 60 * 60 * 1000); + return false; + } + // Reset attempts if more than 15 minutes have passed + this.loginAttempts.set(ip, { count: 1, firstAttempt: Date.now() }); + } + + if (success) { + this.loginAttempts.delete(ip); + } + + return !this.blockedIPs.has(ip); + } + + isIPBlocked(ip) { + return this.blockedIPs.has(ip); + } + + async updateSecuritySettings(userId, settings) { + try { + const updatedUser = await User.findByIdAndUpdate( + userId, + { + $set: { + 'security.twoFactorEnabled': settings.twoFactorEnabled, + 'security.loginNotifications': settings.loginNotifications, + 'security.activityAlerts': settings.activityAlerts + } + }, + { new: true } + ); + return updatedUser.security; + } catch (error) { + console.error('Error updating security settings:', error); + throw error; + } + } + + async getSecurityLogs(userId, limit = 20) { + try { + const user = await User.findById(userId).select('security.logs'); + return user.security.logs.slice(0, limit); + } catch (error) { + console.error('Error getting security logs:', error); + throw error; + } + } +} + +module.exports = new SecurityService(); diff --git a/backend/socket/chatServer.js b/backend/socket/chatServer.js new file mode 100644 index 0000000..03634b7 --- /dev/null +++ b/backend/socket/chatServer.js @@ -0,0 +1,164 @@ +const socketIO = require('socket.io'); +const jwt = require('jsonwebtoken'); +const Message = require('../models/Message'); +const User = require('../models/User'); + +function initializeChatServer(server) { + const io = socketIO(server, { + cors: { + origin: process.env.FRONTEND_URL, + methods: ['GET', 'POST'], + credentials: true, + }, + }); + + // Authentication middleware + io.use(async (socket, next) => { + try { + const token = socket.handshake.auth.token; + if (!token) { + return next(new Error('Authentication error')); + } + + const decoded = jwt.verify(token, process.env.JWT_SECRET); + const user = await User.findById(decoded.userId); + + if (!user) { + return next(new Error('User not found')); + } + + socket.user = user; + next(); + } catch (error) { + next(new Error('Authentication error')); + } + }); + + // Store active users + const activeUsers = new Map(); + const userSockets = new Map(); + + io.on('connection', (socket) => { + const userId = socket.user._id.toString(); + const propertyId = socket.handshake.query.propertyId; + + // Add user to active users + activeUsers.set(userId, socket.user); + + // Store socket reference + if (!userSockets.has(userId)) { + userSockets.set(userId, new Set()); + } + userSockets.get(userId).add(socket); + + // Join property-specific room + if (propertyId) { + socket.join(`property:${propertyId}`); + } + + console.log(`User connected: ${userId} for property: ${propertyId}`); + + // Handle new message + socket.on('message', async (messageData) => { + try { + const { content, recipient, propertyId, expiresAt } = messageData; + + // Create and save message + const message = new Message({ + content, + sender: socket.user._id, + recipient, + propertyId, + expiresAt, + }); + await message.save(); + + // Broadcast to property room + io.to(`property:${propertyId}`).emit('message', { + ...message.toJSON(), + sender: { + _id: socket.user._id, + name: socket.user.name, + avatar: socket.user.avatar, + }, + }); + + // Schedule message expiration + if (expiresAt) { + const expiryTime = new Date(expiresAt).getTime() - Date.now(); + setTimeout(async () => { + await Message.deleteOne({ _id: message._id }); + io.to(`property:${propertyId}`).emit('messageExpired', message._id); + }, expiryTime); + } + } catch (error) { + console.error('Error sending message:', error); + socket.emit('error', 'Failed to send message'); + } + }); + + // Handle typing events + socket.on('typing', ({ propertyId, recipientId }) => { + socket.to(`property:${propertyId}`).emit('typing', { + userId: socket.user._id, + username: socket.user.name, + }); + }); + + socket.on('stopTyping', ({ propertyId, recipientId }) => { + socket.to(`property:${propertyId}`).emit('stopTyping', { + userId: socket.user._id, + }); + }); + + // Handle message deletion + socket.on('messageDeleted', async ({ messageId, propertyId, recipientId }) => { + try { + await Message.deleteOne({ + _id: messageId, + sender: socket.user._id, + }); + + io.to(`property:${propertyId}`).emit('messageDeleted', messageId); + } catch (error) { + console.error('Error deleting message:', error); + socket.emit('error', 'Failed to delete message'); + } + }); + + // Handle disconnection + socket.on('disconnect', () => { + // Remove socket reference + const userSocketSet = userSockets.get(userId); + if (userSocketSet) { + userSocketSet.delete(socket); + if (userSocketSet.size === 0) { + userSockets.delete(userId); + activeUsers.delete(userId); + } + } + + console.log(`User disconnected: ${userId}`); + }); + }); + + // Periodic cleanup of expired messages + setInterval(async () => { + try { + const expiredMessages = await Message.find({ + expiresAt: { $lte: new Date() }, + }); + + for (const message of expiredMessages) { + await Message.deleteOne({ _id: message._id }); + io.to(`property:${message.propertyId}`).emit('messageExpired', message._id); + } + } catch (error) { + console.error('Error cleaning up expired messages:', error); + } + }, 60000); // Check every minute + + return io; +} + +module.exports = initializeChatServer; diff --git a/backend/utils/errorHandler.js b/backend/utils/errorHandler.js new file mode 100644 index 0000000..f44355b --- /dev/null +++ b/backend/utils/errorHandler.js @@ -0,0 +1,121 @@ +class AppError extends Error { + constructor(message, statusCode) { + super(message); + this.statusCode = statusCode; + this.status = `${statusCode}`.startsWith('4') ? 'fail' : 'error'; + this.isOperational = true; + + Error.captureStackTrace(this, this.constructor); + } +} + +class ValidationError extends AppError { + constructor(message) { + super(message, 400); + this.name = 'ValidationError'; + this.validationErrors = {}; + } + + addError(field, message) { + this.validationErrors[field] = message; + return this; + } +} + +class AuthenticationError extends AppError { + constructor(message = 'Authentication failed') { + super(message, 401); + this.name = 'AuthenticationError'; + } +} + +class AuthorizationError extends AppError { + constructor(message = 'You do not have permission to perform this action') { + super(message, 403); + this.name = 'AuthorizationError'; + } +} + +class NotFoundError extends AppError { + constructor(resource = 'Resource') { + super(`${resource} not found`, 404); + this.name = 'NotFoundError'; + } +} + +class DuplicateError extends AppError { + constructor(message = 'Duplicate entry') { + super(message, 409); + this.name = 'DuplicateError'; + } +} + +// Error handler for async functions +const catchAsync = (fn) => { + return (req, res, next) => { + Promise.resolve(fn(req, res, next)).catch(next); + }; +}; + +// Format Mongoose validation errors +const formatMongooseError = (err) => { + const error = new ValidationError('Validation failed'); + + if (err.errors) { + Object.keys(err.errors).forEach((field) => { + error.addError(field, err.errors[field].message); + }); + } + + return error; +}; + +// Format JWT errors +const formatJWTError = (err) => { + if (err.name === 'JsonWebTokenError') { + return new AuthenticationError('Invalid token'); + } + if (err.name === 'TokenExpiredError') { + return new AuthenticationError('Token expired'); + } + return err; +}; + +// Format Multer errors +const formatMulterError = (err) => { + if (err.code === 'LIMIT_FILE_SIZE') { + return new ValidationError('File size too large'); + } + if (err.code === 'LIMIT_FILE_COUNT') { + return new ValidationError('Too many files'); + } + if (err.code === 'LIMIT_UNEXPECTED_FILE') { + return new ValidationError('Invalid file type'); + } + return err; +}; + +// Format MongoDB errors +const formatMongoError = (err) => { + if (err.code === 11000) { + const field = Object.keys(err.keyPattern)[0]; + return new DuplicateError( + `A record with this ${field} already exists` + ); + } + return err; +}; + +module.exports = { + AppError, + ValidationError, + AuthenticationError, + AuthorizationError, + NotFoundError, + DuplicateError, + catchAsync, + formatMongooseError, + formatJWTError, + formatMulterError, + formatMongoError, +}; diff --git a/backend/utils/fileUpload.js b/backend/utils/fileUpload.js new file mode 100644 index 0000000..965ac91 --- /dev/null +++ b/backend/utils/fileUpload.js @@ -0,0 +1,61 @@ +const multer = require('multer'); +const path = require('path'); +const fs = require('fs'); +const { v4: uuidv4 } = require('uuid'); + +// Configure multer for file upload +const storage = multer.diskStorage({ + destination: (req, file, cb) => { + const uploadDir = 'uploads/profiles'; + // Create directory if it doesn't exist + if (!fs.existsSync(uploadDir)) { + fs.mkdirSync(uploadDir, { recursive: true }); + } + cb(null, uploadDir); + }, + filename: (req, file, cb) => { + // Generate unique filename with original extension + const uniqueFilename = `${uuidv4()}${path.extname(file.originalname)}`; + cb(null, uniqueFilename); + } +}); + +// File filter +const fileFilter = (req, file, cb) => { + const allowedTypes = ['image/jpeg', 'image/png', 'image/gif']; + + if (allowedTypes.includes(file.mimetype)) { + cb(null, true); + } else { + cb(new Error('Invalid file type. Only JPEG, PNG and GIF are allowed'), false); + } +}; + +// Configure upload +exports.upload = multer({ + storage: storage, + fileFilter: fileFilter, + limits: { + fileSize: 5 * 1024 * 1024 // 5MB limit + } +}); + +// Delete file +exports.deleteFile = async (filepath) => { + try { + if (fs.existsSync(filepath)) { + await fs.promises.unlink(filepath); + return true; + } + return false; + } catch (error) { + console.error('Error deleting file:', error); + return false; + } +}; + +// Get file URL +exports.getFileUrl = (filename) => { + if (!filename) return null; + return `/uploads/profiles/${filename}`; +}; diff --git a/backend/utils/imagePlaceholder.js b/backend/utils/imagePlaceholder.js new file mode 100644 index 0000000..d49ac32 --- /dev/null +++ b/backend/utils/imagePlaceholder.js @@ -0,0 +1,94 @@ +const placeholderImages = { + property: { + apartment: 'https://images.pexels.com/photos/1571460/pexels-photo-1571460.jpeg', + house: 'https://images.pexels.com/photos/106399/pexels-photo-106399.jpeg', + office: 'https://images.pexels.com/photos/1743555/pexels-photo-1743555.jpeg', + mess: 'https://images.pexels.com/photos/1454806/pexels-photo-1454806.jpeg', + shop: 'https://images.pexels.com/photos/264507/pexels-photo-264507.jpeg' + }, + room: { + bedroom: 'https://images.pexels.com/photos/1454806/pexels-photo-1454806.jpeg', + livingRoom: 'https://images.pexels.com/photos/2462015/pexels-photo-2462015.jpeg', + kitchen: 'https://images.pexels.com/photos/1457842/pexels-photo-1457842.jpeg', + bathroom: 'https://images.pexels.com/photos/6585757/pexels-photo-6585757.jpeg' + }, + amenity: { + lift: 'https://images.pexels.com/photos/8241135/pexels-photo-8241135.jpeg', + generator: 'https://images.pexels.com/photos/8566472/pexels-photo-8566472.jpeg', + security: 'https://images.pexels.com/photos/3205735/pexels-photo-3205735.jpeg', + parking: 'https://images.pexels.com/photos/1004665/pexels-photo-1004665.jpeg', + prayerRoom: 'https://images.pexels.com/photos/6646918/pexels-photo-6646918.jpeg' + }, + area: { + dhaka: { + gulshan: 'https://images.pexels.com/photos/2096700/pexels-photo-2096700.jpeg', + banani: 'https://images.pexels.com/photos/2096700/pexels-photo-2096700.jpeg', + dhanmondi: 'https://images.pexels.com/photos/2096700/pexels-photo-2096700.jpeg', + uttara: 'https://images.pexels.com/photos/2096700/pexels-photo-2096700.jpeg', + mohammadpur: 'https://images.pexels.com/photos/2096700/pexels-photo-2096700.jpeg' + } + }, + profile: { + default: 'https://images.pexels.com/photos/771742/pexels-photo-771742.jpeg', + business: 'https://images.pexels.com/photos/3760263/pexels-photo-3760263.jpeg' + } +}; + +/** + * Get a placeholder image URL based on the type and category + * @param {string} type - Main category (property, room, amenity, area, profile) + * @param {string} subType - Sub-category + * @param {string} [area] - Area name for area type + * @returns {string} Placeholder image URL + */ +function getPlaceholderImage(type, subType, area = null) { + try { + if (type === 'area' && area) { + return placeholderImages.area[area.toLowerCase()]?.[subType.toLowerCase()] + || placeholderImages.area.dhaka.gulshan; // Default area image + } + + return placeholderImages[type.toLowerCase()]?.[subType.toLowerCase()] + || placeholderImages.property.apartment; // Default property image + } catch (error) { + console.error('Error getting placeholder image:', error); + return placeholderImages.property.apartment; // Fallback default + } +} + +/** + * Check if an image URL is valid + * @param {string} url - Image URL to check + * @returns {Promise} True if image is valid + */ +async function isImageValid(url) { + try { + const response = await fetch(url, { method: 'HEAD' }); + const contentType = response.headers.get('content-type'); + return response.ok && contentType.startsWith('image/'); + } catch (error) { + console.error('Error checking image validity:', error); + return false; + } +} + +/** + * Get a valid image URL or return a placeholder + * @param {string} imageUrl - Original image URL + * @param {string} type - Type of placeholder needed + * @param {string} subType - Sub-type of placeholder + * @param {string} [area] - Area name for area type + * @returns {Promise} Valid image URL or placeholder + */ +async function getValidImageUrl(imageUrl, type, subType, area = null) { + if (imageUrl && await isImageValid(imageUrl)) { + return imageUrl; + } + return getPlaceholderImage(type, subType, area); +} + +module.exports = { + getPlaceholderImage, + isImageValid, + getValidImageUrl +}; diff --git a/backend/utils/imageUpload.js b/backend/utils/imageUpload.js new file mode 100644 index 0000000..f61d951 --- /dev/null +++ b/backend/utils/imageUpload.js @@ -0,0 +1,58 @@ +const fs = require('fs'); +const path = require('path'); +const sharp = require('sharp'); + +// Create upload directory if it doesn't exist +const createUploadDir = () => { + const uploadDir = path.join(process.cwd(), 'public', 'uploads', 'properties'); + if (!fs.existsSync(uploadDir)) { + fs.mkdirSync(uploadDir, { recursive: true }); + } + return uploadDir; +}; + +// Process uploaded image +const processImage = async (file) => { + const uploadDir = createUploadDir(); + const filename = `processed-${file.filename}`; + const outputPath = path.join(uploadDir, filename); + + try { + // Resize and optimize image + await sharp(file.path) + .resize(800, 600, { // Standard size for property images + fit: 'inside', + withoutEnlargement: true + }) + .jpeg({ quality: 80 }) // Convert to JPEG and compress + .toFile(outputPath); + + // Delete original file + fs.unlinkSync(file.path); + + // Return processed image details + return { + url: `/uploads/properties/${filename}`, + filename: filename + }; + } catch (error) { + // If something goes wrong, delete both files + if (fs.existsSync(file.path)) fs.unlinkSync(file.path); + if (fs.existsSync(outputPath)) fs.unlinkSync(outputPath); + throw error; + } +}; + +// Delete image +const deleteImage = (filename) => { + const filepath = path.join(process.cwd(), 'public', 'uploads', 'properties', filename); + if (fs.existsSync(filepath)) { + fs.unlinkSync(filepath); + } +}; + +module.exports = { + processImage, + deleteImage, + createUploadDir +}; diff --git a/backend/utils/security.js b/backend/utils/security.js new file mode 100644 index 0000000..6023afe --- /dev/null +++ b/backend/utils/security.js @@ -0,0 +1,62 @@ +const geoip = require('geoip-lite'); +const UAParser = require('ua-parser-js'); +const speakeasy = require('speakeasy'); +const qrcode = require('qrcode'); + +// Get client information from request +exports.getClientInfo = (req) => { + const ip = req.ip || req.connection.remoteAddress; + const userAgent = req.headers['user-agent']; + const parser = new UAParser(userAgent); + const geo = geoip.lookup(ip); + + return { + ip, + device: `${parser.getBrowser().name} on ${parser.getOS().name}`, + location: geo ? `${geo.city}, ${geo.country}` : 'Unknown Location' + }; +}; + +// Generate backup codes +exports.generateBackupCodes = (count = 8) => { + const codes = []; + const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + + for (let i = 0; i < count; i++) { + let code = ''; + for (let j = 0; j < 8; j++) { + code += possible.charAt(Math.floor(Math.random() * possible.length)); + } + // Add dashes for readability + code = code.match(/.{1,4}/g).join('-'); + codes.push(code); + } + + return codes; +}; + +// Generate 2FA secret +exports.generateTwoFactorSecret = () => { + return speakeasy.generateSecret({ + name: 'House Rental Platform' + }); +}; + +// Verify 2FA token +exports.verifyTwoFactorToken = (secret, token) => { + return speakeasy.totp.verify({ + secret: secret.base32, + encoding: 'base32', + token: token, + window: 1 // Allow 30 seconds clock skew + }); +}; + +// Generate QR code for 2FA +exports.generateQRCode = async (secret) => { + try { + return await qrcode.toDataURL(secret.otpauth_url); + } catch (error) { + throw new Error('Error generating QR code'); + } +}; diff --git a/backend/utils/twoFactor.js b/backend/utils/twoFactor.js new file mode 100644 index 0000000..9b5f6fa --- /dev/null +++ b/backend/utils/twoFactor.js @@ -0,0 +1,41 @@ +const speakeasy = require('speakeasy'); +const qrcode = require('qrcode'); + +// Generate a new secret key for 2FA +exports.generateTwoFactorSecret = () => { + const secret = speakeasy.generateSecret({ + name: 'House Rental BD', + issuer: 'House Rental BD' + }); + return secret.base32; +}; + +// Generate QR code for 2FA setup +exports.generateQRCode = async (secret) => { + const otpauth_url = `otpauth://totp/House%20Rental%20BD?secret=${secret}&issuer=House%20Rental%20BD`; + try { + const qrCodeUrl = await qrcode.toDataURL(otpauth_url); + return qrCodeUrl; + } catch (error) { + throw new Error('Error generating QR code'); + } +}; + +// Verify 2FA token +exports.verifyTwoFactorToken = (token, secret) => { + return speakeasy.totp.verify({ + secret: secret, + encoding: 'base32', + token: token, + window: 1 // Allow 30 seconds clock skew + }); +}; + +// Generate backup codes +exports.generateBackupCodes = () => { + const codes = []; + for (let i = 0; i < 10; i++) { + codes.push(Math.random().toString(36).substr(2, 10).toUpperCase()); + } + return codes; +}; diff --git a/backend/utils/validation.js b/backend/utils/validation.js new file mode 100644 index 0000000..88789d7 --- /dev/null +++ b/backend/utils/validation.js @@ -0,0 +1,115 @@ +// Password validation +exports.validatePassword = (password) => { + // At least 8 characters long + // Contains at least one uppercase letter + // Contains at least one lowercase letter + // Contains at least one number + // Contains at least one special character + const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/; + return passwordRegex.test(password); +}; + +// Email validation +exports.validateEmail = (email) => { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailRegex.test(email); +}; + +// Phone number validation (Bangladesh format) +exports.validatePhone = (phone) => { + // Supports formats: + // +880XXXXXXXXXX + // 880XXXXXXXXXX + // 0XXXXXXXXXX + const phoneRegex = /^(?:\+?88)?01[3-9]\d{8}$/; + return phoneRegex.test(phone); +}; + +// Website URL validation +exports.validateWebsite = (url) => { + if (!url) return true; // Optional field + try { + new URL(url); + return true; + } catch { + return false; + } +}; + +// Profile validation +exports.validateProfile = (profile) => { + const errors = {}; + + if (!profile.firstName || profile.firstName.trim().length < 2) { + errors.firstName = 'First name must be at least 2 characters long'; + } + + if (!profile.lastName || profile.lastName.trim().length < 2) { + errors.lastName = 'Last name must be at least 2 characters long'; + } + + if (profile.phone && !exports.validatePhone(profile.phone)) { + errors.phone = 'Invalid phone number format'; + } + + if (profile.website && !exports.validateWebsite(profile.website)) { + errors.website = 'Invalid website URL'; + } + + if (profile.bio && profile.bio.length > 500) { + errors.bio = 'Bio must not exceed 500 characters'; + } + + return { + isValid: Object.keys(errors).length === 0, + errors + }; +}; + +// Image validation +exports.validateImage = (file) => { + const errors = []; + const allowedTypes = ['image/jpeg', 'image/png', 'image/gif']; + const maxSize = 5 * 1024 * 1024; // 5MB + + if (!allowedTypes.includes(file.mimetype)) { + errors.push('Invalid file type. Only JPEG, PNG and GIF are allowed'); + } + + if (file.size > maxSize) { + errors.push('File size too large. Maximum size is 5MB'); + } + + return { + isValid: errors.length === 0, + errors + }; +}; + +// Theme validation +exports.validateTheme = (theme) => { + const validModes = ['light', 'dark', 'system']; + const validFontSizes = ['small', 'medium', 'large']; + + const errors = {}; + + if (theme.mode && !validModes.includes(theme.mode)) { + errors.mode = 'Invalid theme mode'; + } + + if (theme.fontSize && !validFontSizes.includes(theme.fontSize)) { + errors.fontSize = 'Invalid font size'; + } + + return { + isValid: Object.keys(errors).length === 0, + errors + }; +}; + +// Language validation +exports.validateLanguage = (language) => { + // Add more languages as needed + const validLanguages = ['en', 'bn']; + return validLanguages.includes(language); +}; diff --git a/frontend/components/AdminApproval.js b/frontend/components/AdminApproval.js new file mode 100644 index 0000000..7644c9e --- /dev/null +++ b/frontend/components/AdminApproval.js @@ -0,0 +1,370 @@ +import { useState, useEffect } from 'react'; +import { + Box, + VStack, + HStack, + Text, + Button, + Badge, + useToast, + Tabs, + TabList, + TabPanels, + Tab, + TabPanel, + useColorModeValue, + Avatar, + Flex, + Divider, + Modal, + ModalOverlay, + ModalContent, + ModalHeader, + ModalFooter, + ModalBody, + ModalCloseButton, + useDisclosure, + Textarea, + Select, + Stat, + StatLabel, + StatNumber, + StatGroup, + Icon, +} from '@chakra-ui/react'; +import { + CheckIcon, + CloseIcon, + WarningIcon, + InfoIcon, +} from '@chakra-ui/icons'; +import { format } from 'date-fns'; +import axios from 'axios'; +import PropertyCard from './PropertyCard'; + +const AdminApproval = () => { + const [pendingPosts, setPendingPosts] = useState([]); + const [reportedPosts, setReportedPosts] = useState([]); + const [reportedUsers, setReportedUsers] = useState([]); + const [selectedItem, setSelectedItem] = useState(null); + const [actionReason, setActionReason] = useState(''); + const [actionType, setActionType] = useState(''); + const [stats, setStats] = useState({ + pendingCount: 0, + reportedPostsCount: 0, + reportedUsersCount: 0, + totalApproved: 0, + totalRejected: 0, + }); + + const { isOpen, onOpen, onClose } = useDisclosure(); + const toast = useToast(); + + const bgColor = useColorModeValue('white', 'gray.800'); + const borderColor = useColorModeValue('gray.200', 'gray.700'); + + useEffect(() => { + fetchData(); + }, []); + + const fetchData = async () => { + try { + const [ + pendingResponse, + reportedPostsResponse, + reportedUsersResponse, + statsResponse, + ] = await Promise.all([ + axios.get('/api/admin/pending-posts'), + axios.get('/api/admin/reported-posts'), + axios.get('/api/admin/reported-users'), + axios.get('/api/admin/stats'), + ]); + + setPendingPosts(pendingResponse.data); + setReportedPosts(reportedPostsResponse.data); + setReportedUsers(reportedUsersResponse.data); + setStats(statsResponse.data); + } catch (error) { + toast({ + title: 'Error', + description: 'Failed to fetch data', + status: 'error', + duration: 3000, + isClosable: true, + }); + } + }; + + const handleAction = async () => { + try { + let endpoint; + if (selectedItem.type === 'post') { + endpoint = `/api/admin/posts/${selectedItem.id}/${actionType}`; + } else { + endpoint = `/api/admin/users/${selectedItem.id}/${actionType}`; + } + + await axios.post(endpoint, { reason: actionReason }); + + toast({ + title: 'Success', + description: `${selectedItem.type === 'post' ? 'Post' : 'User'} ${actionType}d successfully`, + status: 'success', + duration: 2000, + isClosable: true, + }); + + fetchData(); + onClose(); + setActionReason(''); + setActionType(''); + } catch (error) { + toast({ + title: 'Error', + description: `Failed to ${actionType} ${selectedItem.type}`, + status: 'error', + duration: 3000, + isClosable: true, + }); + } + }; + + const ActionModal = () => ( + + + + + {actionType.charAt(0).toUpperCase() + actionType.slice(1)}{' '} + {selectedItem?.type} + + + + + +