Spaces:
Running
Running
Samoulla Sync Bot commited on
Commit ·
48bc1c7
0
Parent(s):
Auto-deploy Samoulla Backend: c82f2c9fc666cea51323d5ea793409fcf6d80b82
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitignore +3 -0
- Dockerfile +22 -0
- README.md +13 -0
- app.js +179 -0
- broken-url.txt +1 -0
- config/cloudinary.js +216 -0
- config/samoulla-490417-daa7176c0091.json +13 -0
- controllers/.DS_Store +0 -0
- controllers/addressController.js +87 -0
- controllers/authController.js +537 -0
- controllers/brandController.js +162 -0
- controllers/cartController.js +279 -0
- controllers/categoryController.js +208 -0
- controllers/contentController.js +705 -0
- controllers/favoriteController.js +106 -0
- controllers/financeController.js +1711 -0
- controllers/imageController.js +210 -0
- controllers/newsletterController.js +157 -0
- controllers/notificationController.js +825 -0
- controllers/orderController.js +1317 -0
- controllers/paymentController.js +516 -0
- controllers/productController.js +2191 -0
- controllers/promoController.js +225 -0
- controllers/providerController.js +317 -0
- controllers/returnRequestController.js +85 -0
- controllers/reviewController.js +129 -0
- controllers/settingsController.js +44 -0
- controllers/shippingController.js +179 -0
- controllers/userManagementController.js +473 -0
- controllers/usersController.js +191 -0
- controllers/vendorRequestController.js +212 -0
- controllers/visitController.js +211 -0
- devData/.DS_Store +0 -0
- devData/brands.json +21 -0
- devData/broken-images.json +1 -0
- devData/categories.json +39 -0
- devData/dump-broken.js +18 -0
- devData/fix-provider-ids.js +57 -0
- devData/import-brands.js +54 -0
- devData/import-categories.js +84 -0
- devData/importProducts.js +55 -0
- devData/inspect-broken.js +17 -0
- devData/list-providers.js +15 -0
- devData/products-sample.json +448 -0
- devData/providers-dump.json +14 -0
- devData/shift-products.js +64 -0
- devData/test-precise.js +28 -0
- image-formats.json +5 -0
- middlewares/audioUploadMiddleware.js +38 -0
- middlewares/authMiddleware.js +180 -0
.gitignore
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
node_modules/
|
| 2 |
+
config.env
|
| 3 |
+
uploads/*.xlsx
|
Dockerfile
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Use official Node.js image
|
| 2 |
+
FROM node:20-slim
|
| 3 |
+
|
| 4 |
+
# Set working directory
|
| 5 |
+
WORKDIR /app
|
| 6 |
+
|
| 7 |
+
# Copy package files
|
| 8 |
+
COPY package*.json ./
|
| 9 |
+
|
| 10 |
+
# Install dependencies
|
| 11 |
+
# We use --omit=dev to keep the image small for production
|
| 12 |
+
RUN npm install --omit=dev
|
| 13 |
+
|
| 14 |
+
# Copy the rest of the application code
|
| 15 |
+
COPY . .
|
| 16 |
+
|
| 17 |
+
# Hugging Face Spaces uses port 7860 by default
|
| 18 |
+
ENV PORT=7860
|
| 19 |
+
EXPOSE 7860
|
| 20 |
+
|
| 21 |
+
# Start the application
|
| 22 |
+
CMD ["node", "server.js"]
|
README.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Samoulla Backend
|
| 3 |
+
emoji: 🚀
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: indigo
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
pinned: false
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
# Samoulla Backend
|
| 12 |
+
|
| 13 |
+
This is the backend for the Samouulla graduation project, hosted on Hugging Face Spaces.
|
app.js
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const express = require('express');
|
| 2 |
+
const dotenv = require('dotenv');
|
| 3 |
+
|
| 4 |
+
dotenv.config({ path: './config.env' });
|
| 5 |
+
const morgan = require('morgan');
|
| 6 |
+
const helmet = require('helmet');
|
| 7 |
+
const rateLimit = require('express-rate-limit');
|
| 8 |
+
const mongoSanitize = require('express-mongo-sanitize');
|
| 9 |
+
const xss = require('xss-clean');
|
| 10 |
+
const hpp = require('hpp');
|
| 11 |
+
const compression = require('compression');
|
| 12 |
+
const cors = require('cors');
|
| 13 |
+
|
| 14 |
+
const AppError = require('./utils/appError');
|
| 15 |
+
const globalErrorHandler = require('./middlewares/errorMiddleware');
|
| 16 |
+
|
| 17 |
+
const productRouter = require('./routes/productRoutes');
|
| 18 |
+
const authRouter = require('./routes/authRoutes');
|
| 19 |
+
const cartRouter = require('./routes/cartRoutes');
|
| 20 |
+
const orderRouter = require('./routes/orderRoutes');
|
| 21 |
+
const promoRouter = require('./routes/promoRoutes');
|
| 22 |
+
const categoryRouter = require('./routes/categoryRoutes');
|
| 23 |
+
const brandRouter = require('./routes/brandRoutes');
|
| 24 |
+
const providerRouter = require('./routes/providerRoutes');
|
| 25 |
+
const reviewRouter = require('./routes/reviewRouter');
|
| 26 |
+
const addressRoutes = require('./routes/addressRoutes');
|
| 27 |
+
const favoriteRouter = require('./routes/favoriteRoutes');
|
| 28 |
+
const imageRouter = require('./routes/imageRoutes');
|
| 29 |
+
const usersRouter = require('./routes/usersRoutes');
|
| 30 |
+
const adminRouter = require('./routes/adminRoutes');
|
| 31 |
+
const vendorRouter = require('./routes/vendorRoutes');
|
| 32 |
+
const newsletterRouter = require('./routes/newsletterRoutes');
|
| 33 |
+
const notificationRouter = require('./routes/notificationRoutes');
|
| 34 |
+
const contentRouter = require('./routes/contentRoutes');
|
| 35 |
+
const permissionRouter = require('./routes/permissionRoutes');
|
| 36 |
+
const shippingRouter = require('./routes/shippingRoutes');
|
| 37 |
+
const financeRouter = require('./routes/financeRoutes');
|
| 38 |
+
const paymentRouter = require('./routes/paymentRoutes');
|
| 39 |
+
const vendorRequestRouter = require('./routes/vendorRequestRoutes');
|
| 40 |
+
const returnRequestRouter = require('./routes/returnRequestRoutes');
|
| 41 |
+
const visitRouter = require('./routes/visitRoutes');
|
| 42 |
+
|
| 43 |
+
const app = express();
|
| 44 |
+
app.set('trust proxy', 1);
|
| 45 |
+
|
| 46 |
+
// GLOBAL MIDDLEWARES
|
| 47 |
+
|
| 48 |
+
// Implement CORS
|
| 49 |
+
app.use(
|
| 50 |
+
cors({
|
| 51 |
+
origin: [
|
| 52 |
+
'http://localhost:5173',
|
| 53 |
+
'https://samoulla-web.vercel.app',
|
| 54 |
+
'https://samoulla.vercel.app',
|
| 55 |
+
'https://samoulla.com',
|
| 56 |
+
'https://www.samoulla.com',
|
| 57 |
+
process.env.FRONTEND_URL,
|
| 58 |
+
].filter(Boolean),
|
| 59 |
+
credentials: true,
|
| 60 |
+
}),
|
| 61 |
+
);
|
| 62 |
+
|
| 63 |
+
// Set security HTTP headers
|
| 64 |
+
app.use(helmet());
|
| 65 |
+
|
| 66 |
+
// Development logging
|
| 67 |
+
if (process.env.NODE_ENV === 'development') {
|
| 68 |
+
app.use(morgan('dev'));
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
// Limit requests for auth routes (login/register/reset password)
|
| 72 |
+
const authLimiter = rateLimit({
|
| 73 |
+
windowMs: 15 * 60 * 1000, // 15 minutes
|
| 74 |
+
max: 200,
|
| 75 |
+
message:
|
| 76 |
+
'Too many authentication attempts. Please try again after 15 minutes.',
|
| 77 |
+
standardHeaders: true,
|
| 78 |
+
legacyHeaders: false,
|
| 79 |
+
validate: { trustProxy: false },
|
| 80 |
+
});
|
| 81 |
+
|
| 82 |
+
// Limit requests from same API (global browsing)
|
| 83 |
+
const limiter = rateLimit({
|
| 84 |
+
windowMs: 15 * 60 * 1000, // 15 minutes
|
| 85 |
+
max: process.env.NODE_ENV === 'development' ? 10000 : 20000,
|
| 86 |
+
message: 'Too many requests from this IP, please try again in 15 minutes.',
|
| 87 |
+
standardHeaders: true,
|
| 88 |
+
legacyHeaders: false,
|
| 89 |
+
validate: { trustProxy: false },
|
| 90 |
+
});
|
| 91 |
+
|
| 92 |
+
app.use('/samoulla/v1/auth', authLimiter);
|
| 93 |
+
app.use('/samoulla', limiter);
|
| 94 |
+
|
| 95 |
+
// Body parser, reading data from body into req.body
|
| 96 |
+
app.use(express.json({ limit: '10kb' }));
|
| 97 |
+
|
| 98 |
+
// Data sanitization against NoSQL query injection
|
| 99 |
+
app.use(mongoSanitize());
|
| 100 |
+
|
| 101 |
+
// Data sanitization against XSS
|
| 102 |
+
app.use(xss());
|
| 103 |
+
|
| 104 |
+
// Prevent parameter pollution
|
| 105 |
+
app.use(
|
| 106 |
+
hpp({
|
| 107 |
+
whitelist: [
|
| 108 |
+
'price',
|
| 109 |
+
'ratingsAverage',
|
| 110 |
+
'ratingsQuantity',
|
| 111 |
+
'stock',
|
| 112 |
+
'brand',
|
| 113 |
+
'category',
|
| 114 |
+
],
|
| 115 |
+
}),
|
| 116 |
+
);
|
| 117 |
+
|
| 118 |
+
app.use(compression());
|
| 119 |
+
|
| 120 |
+
// HEALTH CHECK ROUTES (must stay lightweight and never be rate-limited)
|
| 121 |
+
app.get('/', (req, res) => {
|
| 122 |
+
res.status(200).json({
|
| 123 |
+
status: 'success',
|
| 124 |
+
message: 'Samoulla Backend is Alive and Runningg! 🚀',
|
| 125 |
+
});
|
| 126 |
+
});
|
| 127 |
+
|
| 128 |
+
app.get('/healthz', (req, res) => {
|
| 129 |
+
res.status(200).json({
|
| 130 |
+
status: 'success',
|
| 131 |
+
service: 'samoulla-backend',
|
| 132 |
+
uptime: process.uptime(),
|
| 133 |
+
timestamp: new Date().toISOString(),
|
| 134 |
+
});
|
| 135 |
+
});
|
| 136 |
+
|
| 137 |
+
app.get('/samoulla/v1/health', (req, res) => {
|
| 138 |
+
res.status(200).json({
|
| 139 |
+
status: 'success',
|
| 140 |
+
service: 'samoulla-backend',
|
| 141 |
+
uptime: process.uptime(),
|
| 142 |
+
timestamp: new Date().toISOString(),
|
| 143 |
+
});
|
| 144 |
+
});
|
| 145 |
+
|
| 146 |
+
// ROUTES
|
| 147 |
+
app.use('/samoulla/v1/products', productRouter);
|
| 148 |
+
app.use('/samoulla/v1/auth', authRouter);
|
| 149 |
+
app.use('/samoulla/v1/cart', cartRouter);
|
| 150 |
+
app.use('/samoulla/v1/orders', orderRouter);
|
| 151 |
+
app.use('/samoulla/v1/promos', promoRouter);
|
| 152 |
+
app.use('/samoulla/v1/categories', categoryRouter);
|
| 153 |
+
app.use('/samoulla/v1/brands', brandRouter);
|
| 154 |
+
app.use('/samoulla/v1/providers', providerRouter);
|
| 155 |
+
app.use('/samoulla/v1/reviews', reviewRouter);
|
| 156 |
+
app.use('/samoulla/v1/addresses', addressRoutes);
|
| 157 |
+
app.use('/samoulla/v1/favorites', favoriteRouter);
|
| 158 |
+
app.use('/samoulla/v1/images', imageRouter);
|
| 159 |
+
app.use('/samoulla/v1/users', usersRouter);
|
| 160 |
+
app.use('/samoulla/v1/admin', adminRouter);
|
| 161 |
+
app.use('/samoulla/v1/vendor', vendorRouter);
|
| 162 |
+
app.use('/samoulla/v1/newsletter', newsletterRouter);
|
| 163 |
+
app.use('/samoulla/v1/notifications', notificationRouter);
|
| 164 |
+
app.use('/samoulla/v1/content', contentRouter);
|
| 165 |
+
app.use('/samoulla/v1/permissions', permissionRouter);
|
| 166 |
+
app.use('/samoulla/v1/shipping', shippingRouter);
|
| 167 |
+
app.use('/samoulla/v1/finance', financeRouter);
|
| 168 |
+
app.use('/samoulla/v1/payment', paymentRouter);
|
| 169 |
+
app.use('/samoulla/v1/vendor-requests', vendorRequestRouter);
|
| 170 |
+
app.use('/samoulla/v1/return-requests', returnRequestRouter);
|
| 171 |
+
app.use('/samoulla/v1/visits', visitRouter);
|
| 172 |
+
|
| 173 |
+
app.all('*', (req, res, next) => {
|
| 174 |
+
next(new AppError(`Can't find ${req.originalUrl} on this server!`, 404));
|
| 175 |
+
});
|
| 176 |
+
|
| 177 |
+
app.use(globalErrorHandler);
|
| 178 |
+
|
| 179 |
+
module.exports = app;
|
broken-url.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
https://res.cloudinary.com/dm9ym99zh/image/upload/v1771515557/samoulla/products/6996da5201c93defc57509d4/cover-1771515557455.jpg
|
config/cloudinary.js
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const cloudinary = require('cloudinary').v2;
|
| 2 |
+
const { CloudinaryStorage } = require('multer-storage-cloudinary');
|
| 3 |
+
const multer = require('multer');
|
| 4 |
+
|
| 5 |
+
// Configure Cloudinary with your credentials
|
| 6 |
+
cloudinary.config({
|
| 7 |
+
cloud_name: process.env.CLOUDINARY_CLOUD_NAME,
|
| 8 |
+
api_key: process.env.CLOUDINARY_API_KEY,
|
| 9 |
+
api_secret: process.env.CLOUDINARY_API_SECRET,
|
| 10 |
+
});
|
| 11 |
+
|
| 12 |
+
const IMAGE_FORMATS = ['jpg', 'jpeg', 'png', 'webp'];
|
| 13 |
+
const VIDEO_FORMATS = ['mp4', 'webm'];
|
| 14 |
+
const IMAGE_MAX_SIZE = 5 * 1024 * 1024; // 5 MB
|
| 15 |
+
const VIDEO_MAX_SIZE = 25 * 1024 * 1024; // 25 MB
|
| 16 |
+
|
| 17 |
+
// Configure Cloudinary storage for product images
|
| 18 |
+
// Supports dynamic folder structure: samoulla/products/{productId}/
|
| 19 |
+
const productStorage = new CloudinaryStorage({
|
| 20 |
+
cloudinary: cloudinary,
|
| 21 |
+
params: async (req, file) => {
|
| 22 |
+
// Get productId from request body or params
|
| 23 |
+
const productId = req.body.productId || req.params.productId || 'temp';
|
| 24 |
+
|
| 25 |
+
// Determine if it's a cover image or gallery image
|
| 26 |
+
const iscover = file.fieldname === 'imageCover';
|
| 27 |
+
const prefix = iscover ? 'cover' : 'img';
|
| 28 |
+
|
| 29 |
+
return {
|
| 30 |
+
folder: `samoulla/products/${productId}`, // Each product gets its own folder
|
| 31 |
+
allowed_formats: IMAGE_FORMATS,
|
| 32 |
+
public_id: `${prefix}-${Date.now()}`, // Unique filename
|
| 33 |
+
transformation: [
|
| 34 |
+
{ width: 1000, height: 1000, crop: 'limit' },
|
| 35 |
+
{ quality: 'auto' },
|
| 36 |
+
{ fetch_format: 'auto' },
|
| 37 |
+
],
|
| 38 |
+
};
|
| 39 |
+
},
|
| 40 |
+
});
|
| 41 |
+
|
| 42 |
+
// Configure Cloudinary storage for category images
|
| 43 |
+
const categoryStorage = new CloudinaryStorage({
|
| 44 |
+
cloudinary: cloudinary,
|
| 45 |
+
params: {
|
| 46 |
+
folder: 'samoulla/categories',
|
| 47 |
+
allowed_formats: IMAGE_FORMATS,
|
| 48 |
+
transformation: [
|
| 49 |
+
{ width: 500, height: 500, crop: 'limit' },
|
| 50 |
+
{ quality: 'auto' },
|
| 51 |
+
{ fetch_format: 'auto' },
|
| 52 |
+
],
|
| 53 |
+
},
|
| 54 |
+
});
|
| 55 |
+
|
| 56 |
+
// Configure Cloudinary storage for brand logos
|
| 57 |
+
const brandStorage = new CloudinaryStorage({
|
| 58 |
+
cloudinary: cloudinary,
|
| 59 |
+
params: {
|
| 60 |
+
folder: 'samoulla/brands',
|
| 61 |
+
allowed_formats: IMAGE_FORMATS,
|
| 62 |
+
transformation: [
|
| 63 |
+
{ width: 300, height: 300, crop: 'limit' },
|
| 64 |
+
{ quality: 'auto' },
|
| 65 |
+
{ fetch_format: 'auto' },
|
| 66 |
+
],
|
| 67 |
+
},
|
| 68 |
+
});
|
| 69 |
+
|
| 70 |
+
// Configure Cloudinary storage for user avatars
|
| 71 |
+
const avatarStorage = new CloudinaryStorage({
|
| 72 |
+
cloudinary: cloudinary,
|
| 73 |
+
params: {
|
| 74 |
+
folder: 'samoulla/avatars',
|
| 75 |
+
allowed_formats: IMAGE_FORMATS,
|
| 76 |
+
transformation: [
|
| 77 |
+
{ width: 300, height: 300, crop: 'fill', gravity: 'face' },
|
| 78 |
+
{ quality: 'auto' },
|
| 79 |
+
{ fetch_format: 'auto' },
|
| 80 |
+
],
|
| 81 |
+
},
|
| 82 |
+
});
|
| 83 |
+
|
| 84 |
+
// Configure Cloudinary storage for provider logos
|
| 85 |
+
const providerStorage = new CloudinaryStorage({
|
| 86 |
+
cloudinary: cloudinary,
|
| 87 |
+
params: {
|
| 88 |
+
folder: 'samoulla/providers',
|
| 89 |
+
allowed_formats: IMAGE_FORMATS,
|
| 90 |
+
transformation: [
|
| 91 |
+
{ width: 300, height: 300, crop: 'limit' },
|
| 92 |
+
{ quality: 'auto' },
|
| 93 |
+
{ fetch_format: 'auto' },
|
| 94 |
+
],
|
| 95 |
+
},
|
| 96 |
+
});
|
| 97 |
+
|
| 98 |
+
const heroImageStorage = new CloudinaryStorage({
|
| 99 |
+
cloudinary: cloudinary,
|
| 100 |
+
params: async (req, file) => {
|
| 101 |
+
const slideId = req.body.slideId || 'temp';
|
| 102 |
+
const type = req.body.type || 'main'; // main or poster
|
| 103 |
+
|
| 104 |
+
return {
|
| 105 |
+
folder: `samoulla/hero-images/${slideId}`,
|
| 106 |
+
allowed_formats: IMAGE_FORMATS,
|
| 107 |
+
public_id: `${type}-${Date.now()}`,
|
| 108 |
+
transformation: [
|
| 109 |
+
{ width: 1920, height: 1080, crop: 'limit' },
|
| 110 |
+
{ quality: 'auto' },
|
| 111 |
+
{ fetch_format: 'auto' },
|
| 112 |
+
],
|
| 113 |
+
};
|
| 114 |
+
},
|
| 115 |
+
});
|
| 116 |
+
|
| 117 |
+
// Configure Cloudinary storage for hero slide videos
|
| 118 |
+
// Quality is baked in at upload time so delivery never needs a paid transformation
|
| 119 |
+
const heroVideoStorage = new CloudinaryStorage({
|
| 120 |
+
cloudinary: cloudinary,
|
| 121 |
+
params: async (req, file) => {
|
| 122 |
+
const slideId = req.body.slideId || 'temp';
|
| 123 |
+
|
| 124 |
+
return {
|
| 125 |
+
folder: `samoulla/hero-images/${slideId}`,
|
| 126 |
+
allowed_formats: VIDEO_FORMATS,
|
| 127 |
+
resource_type: 'video',
|
| 128 |
+
public_id: `video-${Date.now()}`,
|
| 129 |
+
eager: [{ quality: 'auto', fetch_format: 'auto' }],
|
| 130 |
+
eager_async: true, // process in background, don't block the upload response
|
| 131 |
+
};
|
| 132 |
+
},
|
| 133 |
+
});
|
| 134 |
+
|
| 135 |
+
// Create multer upload instances
|
| 136 |
+
const uploadProductImage = multer({
|
| 137 |
+
storage: productStorage,
|
| 138 |
+
limits: { fileSize: IMAGE_MAX_SIZE },
|
| 139 |
+
});
|
| 140 |
+
const uploadCategoryImage = multer({
|
| 141 |
+
storage: categoryStorage,
|
| 142 |
+
limits: { fileSize: IMAGE_MAX_SIZE },
|
| 143 |
+
});
|
| 144 |
+
const uploadBrandLogo = multer({
|
| 145 |
+
storage: brandStorage,
|
| 146 |
+
limits: { fileSize: IMAGE_MAX_SIZE },
|
| 147 |
+
});
|
| 148 |
+
const uploadProviderLogo = multer({
|
| 149 |
+
storage: providerStorage,
|
| 150 |
+
limits: { fileSize: IMAGE_MAX_SIZE },
|
| 151 |
+
});
|
| 152 |
+
const uploadAvatar = multer({
|
| 153 |
+
storage: avatarStorage,
|
| 154 |
+
limits: { fileSize: IMAGE_MAX_SIZE },
|
| 155 |
+
});
|
| 156 |
+
const uploadHeroImage = multer({
|
| 157 |
+
storage: heroImageStorage,
|
| 158 |
+
limits: { fileSize: IMAGE_MAX_SIZE },
|
| 159 |
+
});
|
| 160 |
+
const uploadHeroVideo = multer({
|
| 161 |
+
storage: heroVideoStorage,
|
| 162 |
+
limits: { fileSize: VIDEO_MAX_SIZE },
|
| 163 |
+
});
|
| 164 |
+
|
| 165 |
+
// Helper function to delete image from Cloudinary
|
| 166 |
+
const deleteImage = async (publicId) => {
|
| 167 |
+
try {
|
| 168 |
+
const result = await cloudinary.uploader.destroy(publicId);
|
| 169 |
+
return result;
|
| 170 |
+
} catch (error) {
|
| 171 |
+
console.error('Error deleting image from Cloudinary:', error);
|
| 172 |
+
throw error;
|
| 173 |
+
}
|
| 174 |
+
};
|
| 175 |
+
|
| 176 |
+
// Helper function to delete entire product folder
|
| 177 |
+
const deleteProductFolder = async (productId) => {
|
| 178 |
+
try {
|
| 179 |
+
// Delete all images in the product folder
|
| 180 |
+
const result = await cloudinary.api.delete_resources_by_prefix(
|
| 181 |
+
`samoulla/products/${productId}/`,
|
| 182 |
+
);
|
| 183 |
+
|
| 184 |
+
// Delete the empty folder
|
| 185 |
+
await cloudinary.api.delete_folder(`samoulla/products/${productId}`);
|
| 186 |
+
|
| 187 |
+
return result;
|
| 188 |
+
} catch (error) {
|
| 189 |
+
console.error('Error deleting product folder from Cloudinary:', error);
|
| 190 |
+
throw error;
|
| 191 |
+
}
|
| 192 |
+
};
|
| 193 |
+
|
| 194 |
+
// Helper function to extract public_id from Cloudinary URL
|
| 195 |
+
const getPublicIdFromUrl = (url) => {
|
| 196 |
+
if (!url) return null;
|
| 197 |
+
|
| 198 |
+
// Extract public_id from Cloudinary URL
|
| 199 |
+
// Example URL: https://res.cloudinary.com/demo/image/upload/v1234567890/samoulla/products/abc123.jpg
|
| 200 |
+
const matches = url.match(/\/v\d+\/(.+)\.\w+$/);
|
| 201 |
+
return matches ? matches[1] : null;
|
| 202 |
+
};
|
| 203 |
+
|
| 204 |
+
module.exports = {
|
| 205 |
+
cloudinary,
|
| 206 |
+
uploadProductImage,
|
| 207 |
+
uploadCategoryImage,
|
| 208 |
+
uploadBrandLogo,
|
| 209 |
+
uploadProviderLogo,
|
| 210 |
+
uploadAvatar,
|
| 211 |
+
uploadHeroImage,
|
| 212 |
+
uploadHeroVideo,
|
| 213 |
+
deleteImage,
|
| 214 |
+
deleteProductFolder,
|
| 215 |
+
getPublicIdFromUrl,
|
| 216 |
+
};
|
config/samoulla-490417-daa7176c0091.json
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"type": "service_account",
|
| 3 |
+
"project_id": "samoulla-490417",
|
| 4 |
+
"private_key_id": "daa7176c0091bd157b0e0d82d545fbe2313e1b80",
|
| 5 |
+
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC4PtVGiK3ITi8M\no7mS/XV6pY6+m2yteBAQcICc9+jPBD0+iwnWTW5h2hj+qEjakU4VWSwL1kD81cQr\nXdA/Yo30d+bkk6DcMgRn+5W4dbxo/L7zBwmZIc8deTVI6sG4ohuE2Mm0MCDrGvke\nZxwzLutgSfMP/ev/g1bYP3L+8ZnS8IoUSCXceVoR+XTqp8wPkExOp7dU3bNg+oll\nveE7TS/JspsrSKDAakZDViNEkiI//dCYDpEgzZF3w9HZW4L91V/VZyIrOizcegjv\nnMPurJyeU21Qa76oDs0IGi6OusTvU2hn0gvRSFEoTN8a3K8LphZ/dP0xOkZ1n4DJ\ngoSGBtWHAgMBAAECggEABP/7hLL/2FA62aJ1zkXfksj8YzLPk7OO6AtNijT2Fewd\nB135kd2/EZu8EukZNQk9b7ngRp/1WocokC63EIlJpek9FFXnRyJ6WSIMMZnMV6MI\nQYse2Q+AUeHwrr5kLHEFwyio7KFju02blhYhP0hWLeJD3Nq8tU3opOyv37hJvt4p\nYQLT+DZ1/bRhNKJHs0myy19v1ZJJ7jgWIL+rXCcvl24slQ3JfFmghO21LUeh6sOl\n4IgXMMEMA/0r2oR1IBrTkJZfHXRLygeP/oClwFUZIkVdZxjBlyfguCK+nvpcJ03E\nTVfJBibuCak1QE4CuI4yRS6qlQSscgeaJYefPK5qpQKBgQDviTIjMCHCk75RKoW3\nE15bJHdgx1mIBfT5HmzGZRxIS7KCg+8gkqcdLpvb7vo4OtO/dQtIh8zYoyhDrirF\naXV7YvNoPIgCfu2zOG8dxxdHsC0aUWuG/KhRQ2RF2bha4KDFwESAfLLg+T/5ci2V\nARN3LkqOsqA+5hokQ/0gcF6VewKBgQDE6MMaWIGX5q3fvHb1mJok1F3LN1l9i4cP\nnlvQFami0Lp/rtp3c1eC0gzQXlROJXhHYHsCzvJcajU8T5AUzJc2pmyScg4uWVLB\nOwIkZOyQr1HFANll9ZtT93SFyYYaEwcKtVvU5L9xGzkZjEox7ROeeIr6c3s7NNS4\nQ+rCZQTUZQKBgDekrzjtXWpN19ATCKzWmvyhI/ofVPT8LUQRhUMxCbjhnL4k18/B\nQYDN6vbUNNwLDlVTYyOeKD/K5veR5e2l6dyXx+NW7GFoCt+vJGDOduH4UwHiGBBr\ncM4v0YNIaEL0G2TUnRUb4pHQVMQleeE7NsJgxoEPjZoO6dOy14JJmC8xAoGBAJXc\naJCmh4rqP66mKwtj5vzcu72sFGneRR538Xx+4CpQHYCLvS1oFVQ1NRdok1UeY1o/\nbZ+HjSEUnAuYqhmKVBN9uegC8hQIW1lA5bJ5NSowpFUA/nQA5wSSspYX9/3kOVnH\nCWsP5TvZ8i0lflpdCq9zIqLWPRWkcbkDx6nHZFOZAoGADVyPgj6gHvqTdIREVkSq\naeDyOYPy3jB+3ollJmXEBb2Ns1tyU+hXgTYaIs0uTJs0qQoh0Zf0yNLsN4zBi+z2\n0hj0H5okMOtEyijbRGxCyZWGRmuplKKe2tpi0phZNG3OxBHPRGo/1Uhctwz3soz1\n7nGZ9g0Ap9T3r+R0sKQ0q4s=\n-----END PRIVATE KEY-----\n",
|
| 6 |
+
"client_email": "samoulla-backend@samoulla-490417.iam.gserviceaccount.com",
|
| 7 |
+
"client_id": "101471276909964073550",
|
| 8 |
+
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
| 9 |
+
"token_uri": "https://oauth2.googleapis.com/token",
|
| 10 |
+
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
|
| 11 |
+
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/samoulla-backend%40samoulla-490417.iam.gserviceaccount.com",
|
| 12 |
+
"universe_domain": "googleapis.com"
|
| 13 |
+
}
|
controllers/.DS_Store
ADDED
|
Binary file (6.15 kB). View file
|
|
|
controllers/addressController.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const User = require('../models/userModel');
|
| 2 |
+
//address functions
|
| 3 |
+
exports.addAddress = async (req, res) => {
|
| 4 |
+
try {
|
| 5 |
+
const user = await User.findByIdAndUpdate(
|
| 6 |
+
req.user.id,
|
| 7 |
+
{ $push: { addresses: req.body } },
|
| 8 |
+
{ new: true },
|
| 9 |
+
);
|
| 10 |
+
|
| 11 |
+
// Get the newly added address (last item in array)
|
| 12 |
+
const newAddress = user.addresses[user.addresses.length - 1];
|
| 13 |
+
|
| 14 |
+
res.status(201).json({
|
| 15 |
+
status: 'success',
|
| 16 |
+
message: 'Address added successfully',
|
| 17 |
+
address: newAddress,
|
| 18 |
+
});
|
| 19 |
+
} catch (err) {
|
| 20 |
+
res.status(500).json({ status: 'error', error: err.message });
|
| 21 |
+
}
|
| 22 |
+
};
|
| 23 |
+
|
| 24 |
+
exports.getAddresses = async (req, res) => {
|
| 25 |
+
try {
|
| 26 |
+
const user = await User.findById(req.user.id).select('addresses').lean();
|
| 27 |
+
res.json({
|
| 28 |
+
status: 'success',
|
| 29 |
+
addresses: user.addresses || [],
|
| 30 |
+
});
|
| 31 |
+
} catch (err) {
|
| 32 |
+
res.status(500).json({ status: 'error', error: err.message });
|
| 33 |
+
}
|
| 34 |
+
};
|
| 35 |
+
|
| 36 |
+
exports.updateAddress = async (req, res) => {
|
| 37 |
+
try {
|
| 38 |
+
const user = await User.findOneAndUpdate(
|
| 39 |
+
{ _id: req.user.id, 'addresses._id': req.params.id },
|
| 40 |
+
{ $set: { 'addresses.$': { ...req.body, _id: req.params.id } } },
|
| 41 |
+
{ new: true },
|
| 42 |
+
);
|
| 43 |
+
|
| 44 |
+
if (!user) {
|
| 45 |
+
return res.status(404).json({
|
| 46 |
+
status: 'error',
|
| 47 |
+
message: 'Address not found',
|
| 48 |
+
});
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
const updatedAddress = user.addresses.find(
|
| 52 |
+
(addr) => addr._id.toString() === req.params.id,
|
| 53 |
+
);
|
| 54 |
+
|
| 55 |
+
res.json({
|
| 56 |
+
status: 'success',
|
| 57 |
+
message: 'Address updated successfully',
|
| 58 |
+
address: updatedAddress,
|
| 59 |
+
});
|
| 60 |
+
} catch (err) {
|
| 61 |
+
res.status(500).json({ status: 'error', error: err.message });
|
| 62 |
+
}
|
| 63 |
+
};
|
| 64 |
+
|
| 65 |
+
exports.deleteAddress = async (req, res) => {
|
| 66 |
+
try {
|
| 67 |
+
const user = await User.findByIdAndUpdate(
|
| 68 |
+
req.user.id,
|
| 69 |
+
{ $pull: { addresses: { _id: req.params.id } } },
|
| 70 |
+
{ new: true },
|
| 71 |
+
);
|
| 72 |
+
|
| 73 |
+
if (!user) {
|
| 74 |
+
return res.status(404).json({
|
| 75 |
+
status: 'error',
|
| 76 |
+
message: 'User not found',
|
| 77 |
+
});
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
res.json({
|
| 81 |
+
status: 'success',
|
| 82 |
+
message: 'Address deleted successfully',
|
| 83 |
+
});
|
| 84 |
+
} catch (err) {
|
| 85 |
+
res.status(500).json({ status: 'error', error: err.message });
|
| 86 |
+
}
|
| 87 |
+
};
|
controllers/authController.js
ADDED
|
@@ -0,0 +1,537 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const bcrypt = require('bcryptjs');
|
| 2 |
+
const jwt = require('jsonwebtoken');
|
| 3 |
+
const User = require('../models/userModel');
|
| 4 |
+
const {
|
| 5 |
+
sendWelcomeEmail,
|
| 6 |
+
sendPasswordChangeConfirmationEmail,
|
| 7 |
+
} = require('../utils/emailService');
|
| 8 |
+
|
| 9 |
+
// Helper function to create JWT
|
| 10 |
+
const signToken = (id) => {
|
| 11 |
+
return jwt.sign({ id }, process.env.JWT_SECRET, {
|
| 12 |
+
expiresIn: process.env.JWT_EXPIRES_IN,
|
| 13 |
+
});
|
| 14 |
+
};
|
| 15 |
+
|
| 16 |
+
// SIGNUP
|
| 17 |
+
exports.signup = async (req, res) => {
|
| 18 |
+
try {
|
| 19 |
+
const { name, email, phone, password } = req.body;
|
| 20 |
+
|
| 21 |
+
const existingUser = await User.findOne({ email });
|
| 22 |
+
if (existingUser) {
|
| 23 |
+
return res.status(400).json({ message: 'Email already registered' });
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
// Check if phone number already exists
|
| 27 |
+
const existingPhone = await User.findOne({ phone });
|
| 28 |
+
if (existingPhone) {
|
| 29 |
+
return res
|
| 30 |
+
.status(400)
|
| 31 |
+
.json({ message: 'Phone number already registered' });
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
// Create user - the pre-save hook in userModel will hash the password
|
| 35 |
+
const newUser = await User.create({
|
| 36 |
+
name,
|
| 37 |
+
email,
|
| 38 |
+
phone,
|
| 39 |
+
password, // Will be hashed by pre-save hook
|
| 40 |
+
});
|
| 41 |
+
|
| 42 |
+
const token = signToken(newUser._id);
|
| 43 |
+
|
| 44 |
+
// Send welcome email (non-blocking - don't wait for it)
|
| 45 |
+
sendWelcomeEmail(newUser).catch(() => { });
|
| 46 |
+
|
| 47 |
+
res.status(201).json({
|
| 48 |
+
status: 'success',
|
| 49 |
+
token,
|
| 50 |
+
user: {
|
| 51 |
+
id: newUser._id,
|
| 52 |
+
name: newUser.name,
|
| 53 |
+
email: newUser.email,
|
| 54 |
+
phone: newUser.phone,
|
| 55 |
+
role: newUser.role,
|
| 56 |
+
},
|
| 57 |
+
});
|
| 58 |
+
} catch (err) {
|
| 59 |
+
res.status(500).json({ message: 'Signup failed', error: err.message });
|
| 60 |
+
}
|
| 61 |
+
};
|
| 62 |
+
|
| 63 |
+
// LOGIN
|
| 64 |
+
exports.login = async (req, res) => {
|
| 65 |
+
try {
|
| 66 |
+
const { email, password } = req.body;
|
| 67 |
+
|
| 68 |
+
// 1) Check if email and password exist
|
| 69 |
+
if (!email || !password) {
|
| 70 |
+
return res
|
| 71 |
+
.status(400)
|
| 72 |
+
.json({ message: 'Please provide email and password' });
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
// 2) Check if user exists && password is correct
|
| 76 |
+
const user = await User.findOne({ email })
|
| 77 |
+
.select('+password')
|
| 78 |
+
.populate('provider');
|
| 79 |
+
if (!user) {
|
| 80 |
+
return res.status(401).json({ message: 'Invalid email or password' });
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
const isMatch = await bcrypt.compare(password, user.password);
|
| 84 |
+
if (!isMatch) {
|
| 85 |
+
return res.status(401).json({ message: 'Invalid email or password' });
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
// 3) Check if user account is active
|
| 89 |
+
if (!user.isActive) {
|
| 90 |
+
return res.status(403).json({
|
| 91 |
+
message: 'Your account has been deactivated. Please contact support.',
|
| 92 |
+
});
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
// 4) Check if vendor's provider is active
|
| 96 |
+
if (user.role === 'vendor' && user.provider) {
|
| 97 |
+
if (!user.provider.isActive) {
|
| 98 |
+
return res.status(403).json({
|
| 99 |
+
message:
|
| 100 |
+
'Your vendor account has been deactivated. Please contact support.',
|
| 101 |
+
});
|
| 102 |
+
}
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
// 3) If everything ok, send token
|
| 106 |
+
const token = signToken(user._id);
|
| 107 |
+
|
| 108 |
+
res.status(200).json({
|
| 109 |
+
status: 'success',
|
| 110 |
+
token,
|
| 111 |
+
user: {
|
| 112 |
+
id: user._id,
|
| 113 |
+
name: user.name,
|
| 114 |
+
email: user.email,
|
| 115 |
+
phone: user.phone,
|
| 116 |
+
role: user.role,
|
| 117 |
+
permissions: user.permissions || [],
|
| 118 |
+
provider: user.role === 'vendor' ? user.provider : undefined,
|
| 119 |
+
},
|
| 120 |
+
});
|
| 121 |
+
} catch (err) {
|
| 122 |
+
res.status(500).json({ message: 'Login failed', error: err.message });
|
| 123 |
+
}
|
| 124 |
+
};
|
| 125 |
+
|
| 126 |
+
// ADMIN PORTAL LOGIN - Only for admin, employee, and vendor roles
|
| 127 |
+
exports.adminLogin = async (req, res) => {
|
| 128 |
+
try {
|
| 129 |
+
const { email, password } = req.body;
|
| 130 |
+
|
| 131 |
+
// 1) Check if email and password exist
|
| 132 |
+
if (!email || !password) {
|
| 133 |
+
return res
|
| 134 |
+
.status(400)
|
| 135 |
+
.json({ message: 'Please provide email and password' });
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
// 2) Check if user exists && password is correct
|
| 139 |
+
const user = await User.findOne({ email })
|
| 140 |
+
.select('+password')
|
| 141 |
+
.populate('provider');
|
| 142 |
+
if (!user) {
|
| 143 |
+
return res.status(401).json({ message: 'Invalid email or password' });
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
const isMatch = await bcrypt.compare(password, user.password);
|
| 147 |
+
if (!isMatch) {
|
| 148 |
+
return res.status(401).json({ message: 'Invalid email or password' });
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
// 3) Check if user has admin portal access (admin, employee, or vendor)
|
| 152 |
+
const allowedRoles = ['admin', 'employee', 'vendor'];
|
| 153 |
+
if (!allowedRoles.includes(user.role)) {
|
| 154 |
+
return res.status(403).json({
|
| 155 |
+
message:
|
| 156 |
+
'Access denied. This portal is only for administrators, employees, and vendors.',
|
| 157 |
+
});
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
// 4) Check if user account is active
|
| 161 |
+
if (!user.isActive) {
|
| 162 |
+
return res.status(403).json({
|
| 163 |
+
message: 'Your account has been deactivated. Please contact support.',
|
| 164 |
+
});
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
// 5) Check if vendor's provider is active
|
| 168 |
+
if (user.role === 'vendor' && user.provider) {
|
| 169 |
+
if (!user.provider.isActive) {
|
| 170 |
+
return res.status(403).json({
|
| 171 |
+
message:
|
| 172 |
+
'Your vendor account has been deactivated. Please contact support.',
|
| 173 |
+
});
|
| 174 |
+
}
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
// 6) If everything ok, send token
|
| 178 |
+
const token = signToken(user._id);
|
| 179 |
+
|
| 180 |
+
res.status(200).json({
|
| 181 |
+
status: 'success',
|
| 182 |
+
token,
|
| 183 |
+
user: {
|
| 184 |
+
id: user._id,
|
| 185 |
+
name: user.name,
|
| 186 |
+
email: user.email,
|
| 187 |
+
phone: user.phone,
|
| 188 |
+
role: user.role,
|
| 189 |
+
permissions: user.permissions || [],
|
| 190 |
+
provider: user.role === 'vendor' ? user.provider : undefined,
|
| 191 |
+
},
|
| 192 |
+
});
|
| 193 |
+
} catch (err) {
|
| 194 |
+
res.status(500).json({ message: 'Admin login failed', error: err.message });
|
| 195 |
+
}
|
| 196 |
+
};
|
| 197 |
+
|
| 198 |
+
exports.getMe = async (req, res) => {
|
| 199 |
+
try {
|
| 200 |
+
const user = await User.findById(req.user.id).populate('provider');
|
| 201 |
+
if (!user) {
|
| 202 |
+
return res.status(404).json({ message: 'User not found' });
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
res.status(200).json({
|
| 206 |
+
status: 'success',
|
| 207 |
+
user: {
|
| 208 |
+
id: user._id,
|
| 209 |
+
name: user.name,
|
| 210 |
+
email: user.email,
|
| 211 |
+
phone: user.phone,
|
| 212 |
+
role: user.role,
|
| 213 |
+
permissions: user.permissions || [],
|
| 214 |
+
provider: user.role === 'vendor' ? user.provider : undefined,
|
| 215 |
+
},
|
| 216 |
+
});
|
| 217 |
+
} catch (err) {
|
| 218 |
+
res
|
| 219 |
+
.status(500)
|
| 220 |
+
.json({ message: 'Failed to get user details', error: err.message });
|
| 221 |
+
}
|
| 222 |
+
};
|
| 223 |
+
|
| 224 |
+
exports.protect = async (req, res, next) => {
|
| 225 |
+
try {
|
| 226 |
+
let token;
|
| 227 |
+
|
| 228 |
+
// 1) Check if token exists in headers
|
| 229 |
+
if (
|
| 230 |
+
req.headers.authorization &&
|
| 231 |
+
req.headers.authorization.startsWith('Bearer')
|
| 232 |
+
) {
|
| 233 |
+
token = req.headers.authorization.split(' ')[1];
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
if (!token) {
|
| 237 |
+
return res.status(401).json({ message: 'You are not logged in!' });
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
// 2) Verify token
|
| 241 |
+
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
| 242 |
+
|
| 243 |
+
// 3) Check if user still exists
|
| 244 |
+
const currentUser = await User.findById(decoded.id);
|
| 245 |
+
if (!currentUser) {
|
| 246 |
+
return res.status(401).json({ message: 'User no longer exists.' });
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
// 4) Check if user is active
|
| 250 |
+
if (!currentUser.isActive) {
|
| 251 |
+
return res.status(403).json({
|
| 252 |
+
message: 'Your account has been deactivated. Please contact support.',
|
| 253 |
+
});
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
// 5) Grant access
|
| 257 |
+
req.user = currentUser;
|
| 258 |
+
next();
|
| 259 |
+
} catch (err) {
|
| 260 |
+
res
|
| 261 |
+
.status(401)
|
| 262 |
+
.json({ message: 'Invalid or expired token', error: err.message });
|
| 263 |
+
}
|
| 264 |
+
};
|
| 265 |
+
|
| 266 |
+
// Optional authentication - sets req.user if token is valid, but doesn't fail if no token
|
| 267 |
+
exports.optionalAuth = async (req, res, next) => {
|
| 268 |
+
try {
|
| 269 |
+
let token;
|
| 270 |
+
|
| 271 |
+
// 1) Check if token exists in headers
|
| 272 |
+
if (
|
| 273 |
+
req.headers.authorization &&
|
| 274 |
+
req.headers.authorization.startsWith('Bearer')
|
| 275 |
+
) {
|
| 276 |
+
token = req.headers.authorization.split(' ')[1];
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
// If no token, just continue without setting req.user
|
| 280 |
+
if (!token) {
|
| 281 |
+
return next();
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
// 2) Verify token
|
| 285 |
+
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
| 286 |
+
|
| 287 |
+
// 3) Check if user still exists
|
| 288 |
+
const currentUser = await User.findById(decoded.id);
|
| 289 |
+
if (currentUser) {
|
| 290 |
+
req.user = currentUser;
|
| 291 |
+
}
|
| 292 |
+
|
| 293 |
+
next();
|
| 294 |
+
} catch (err) {
|
| 295 |
+
// If token is invalid, just continue without setting req.user
|
| 296 |
+
next();
|
| 297 |
+
}
|
| 298 |
+
};
|
| 299 |
+
|
| 300 |
+
exports.logout = (req, res) => {
|
| 301 |
+
res
|
| 302 |
+
.status(200)
|
| 303 |
+
.json({ status: 'success', message: 'Logged out successfully' });
|
| 304 |
+
};
|
| 305 |
+
|
| 306 |
+
exports.deleteAccount = async (req, res) => {
|
| 307 |
+
try {
|
| 308 |
+
const { currentPassword } = req.body;
|
| 309 |
+
|
| 310 |
+
// 1) Check if password is provided
|
| 311 |
+
if (!currentPassword) {
|
| 312 |
+
return res
|
| 313 |
+
.status(400)
|
| 314 |
+
.json({ message: 'Password is required to delete account' });
|
| 315 |
+
}
|
| 316 |
+
|
| 317 |
+
// 2) Get user with password
|
| 318 |
+
const user = await User.findById(req.user._id).select('+password');
|
| 319 |
+
|
| 320 |
+
// 3) Verify password
|
| 321 |
+
const isMatch = await bcrypt.compare(currentPassword, user.password);
|
| 322 |
+
if (!isMatch) {
|
| 323 |
+
return res.status(401).json({ message: 'Incorrect password' });
|
| 324 |
+
}
|
| 325 |
+
|
| 326 |
+
// 4) Delete the account
|
| 327 |
+
await User.findByIdAndDelete(req.user._id);
|
| 328 |
+
|
| 329 |
+
res.status(200).json({
|
| 330 |
+
status: 'success',
|
| 331 |
+
message: 'Account deleted successfully',
|
| 332 |
+
});
|
| 333 |
+
} catch (err) {
|
| 334 |
+
res
|
| 335 |
+
.status(500)
|
| 336 |
+
.json({ message: 'Failed to delete account', error: err.message });
|
| 337 |
+
}
|
| 338 |
+
};
|
| 339 |
+
|
| 340 |
+
exports.updateMe = async (req, res) => {
|
| 341 |
+
try {
|
| 342 |
+
const { name, email, phone, city, currentPassword } = req.body;
|
| 343 |
+
|
| 344 |
+
// 1) لازم يكتب الباسورد القديمة
|
| 345 |
+
if (!currentPassword) {
|
| 346 |
+
return res
|
| 347 |
+
.status(400)
|
| 348 |
+
.json({ message: 'You must enter your current password' });
|
| 349 |
+
}
|
| 350 |
+
|
| 351 |
+
// 2) نجيب المستخدم ونقارن الباسورد
|
| 352 |
+
const user = await User.findById(req.user._id).select('+password');
|
| 353 |
+
|
| 354 |
+
const isMatch = await bcrypt.compare(currentPassword, user.password);
|
| 355 |
+
if (!isMatch) {
|
| 356 |
+
return res.status(401).json({ message: 'Current password is incorrect' });
|
| 357 |
+
}
|
| 358 |
+
|
| 359 |
+
// 3) نعمل update للبيانات
|
| 360 |
+
user.name = name || user.name;
|
| 361 |
+
user.email = email || user.email;
|
| 362 |
+
user.phone = phone || user.phone;
|
| 363 |
+
user.city = city || user.city;
|
| 364 |
+
|
| 365 |
+
await user.save();
|
| 366 |
+
|
| 367 |
+
res.status(200).json({
|
| 368 |
+
status: 'success',
|
| 369 |
+
user: {
|
| 370 |
+
id: user._id,
|
| 371 |
+
name: user.name,
|
| 372 |
+
email: user.email,
|
| 373 |
+
phone: user.phone,
|
| 374 |
+
city: user.city,
|
| 375 |
+
},
|
| 376 |
+
});
|
| 377 |
+
} catch (err) {
|
| 378 |
+
res.status(500).json({
|
| 379 |
+
status: 'fail',
|
| 380 |
+
message: 'Update failed',
|
| 381 |
+
error: err.message,
|
| 382 |
+
});
|
| 383 |
+
}
|
| 384 |
+
};
|
| 385 |
+
|
| 386 |
+
exports.changePassword = async (req, res) => {
|
| 387 |
+
try {
|
| 388 |
+
const { currentPassword, newPassword, confirmNewPassword } = req.body;
|
| 389 |
+
|
| 390 |
+
// 1) Check all fields exist
|
| 391 |
+
if (!currentPassword || !newPassword || !confirmNewPassword) {
|
| 392 |
+
return res.status(400).json({ message: 'All fields are required' });
|
| 393 |
+
}
|
| 394 |
+
|
| 395 |
+
// 2) Check new passwords match
|
| 396 |
+
if (newPassword !== confirmNewPassword) {
|
| 397 |
+
return res.status(400).json({ message: 'New passwords do not match' });
|
| 398 |
+
}
|
| 399 |
+
|
| 400 |
+
// 3) Get current user with password
|
| 401 |
+
const user = await User.findById(req.user._id).select('+password');
|
| 402 |
+
|
| 403 |
+
// 4) Check currentPassword is correct
|
| 404 |
+
const isMatch = await bcrypt.compare(currentPassword, user.password);
|
| 405 |
+
if (!isMatch) {
|
| 406 |
+
return res.status(401).json({ message: 'Current password is incorrect' });
|
| 407 |
+
}
|
| 408 |
+
|
| 409 |
+
// 5) Set new password (will be hashed by pre-save hook)
|
| 410 |
+
user.password = newPassword;
|
| 411 |
+
|
| 412 |
+
// 6) Save user
|
| 413 |
+
await user.save();
|
| 414 |
+
|
| 415 |
+
// 7) Send password change confirmation email
|
| 416 |
+
sendPasswordChangeConfirmationEmail(user).catch((err) =>
|
| 417 |
+
console.error(
|
| 418 |
+
'Failed to send password change confirmation email:',
|
| 419 |
+
err.message,
|
| 420 |
+
),
|
| 421 |
+
);
|
| 422 |
+
|
| 423 |
+
res.status(200).json({
|
| 424 |
+
status: 'success',
|
| 425 |
+
message: 'Password updated successfully',
|
| 426 |
+
});
|
| 427 |
+
} catch (err) {
|
| 428 |
+
res
|
| 429 |
+
.status(500)
|
| 430 |
+
.json({ message: 'Failed to update password', error: err.message });
|
| 431 |
+
}
|
| 432 |
+
};
|
| 433 |
+
|
| 434 |
+
// FORGOT PASSWORD - sends reset token to email
|
| 435 |
+
exports.forgotPassword = async (req, res) => {
|
| 436 |
+
try {
|
| 437 |
+
const { email } = req.body;
|
| 438 |
+
|
| 439 |
+
if (!email) {
|
| 440 |
+
return res
|
| 441 |
+
.status(400)
|
| 442 |
+
.json({ message: 'Please provide an email address' });
|
| 443 |
+
}
|
| 444 |
+
|
| 445 |
+
const user = await User.findOne({ email });
|
| 446 |
+
if (!user) {
|
| 447 |
+
return res
|
| 448 |
+
.status(404)
|
| 449 |
+
.json({ message: 'No user found with that email address' });
|
| 450 |
+
}
|
| 451 |
+
|
| 452 |
+
const resetToken = user.createPasswordResetToken();
|
| 453 |
+
await user.save({ validateBeforeSave: false });
|
| 454 |
+
|
| 455 |
+
const { sendPasswordResetEmail } = require('../utils/emailService');
|
| 456 |
+
|
| 457 |
+
try {
|
| 458 |
+
await sendPasswordResetEmail(user, resetToken);
|
| 459 |
+
|
| 460 |
+
res.status(200).json({
|
| 461 |
+
status: 'success',
|
| 462 |
+
message: 'Password reset link sent to email',
|
| 463 |
+
});
|
| 464 |
+
} catch (err) {
|
| 465 |
+
user.passwordResetToken = undefined;
|
| 466 |
+
user.passwordResetExpires = undefined;
|
| 467 |
+
await user.save({ validateBeforeSave: false });
|
| 468 |
+
|
| 469 |
+
return res.status(500).json({
|
| 470 |
+
message: 'Failed to send password reset email. Please try again later.',
|
| 471 |
+
error: err.message,
|
| 472 |
+
});
|
| 473 |
+
}
|
| 474 |
+
} catch (err) {
|
| 475 |
+
res.status(500).json({
|
| 476 |
+
message: 'Failed to process forgot password request',
|
| 477 |
+
error: err.message,
|
| 478 |
+
});
|
| 479 |
+
}
|
| 480 |
+
};
|
| 481 |
+
|
| 482 |
+
// RESET PASSWORD - validates token and sets new password
|
| 483 |
+
exports.resetPassword = async (req, res) => {
|
| 484 |
+
try {
|
| 485 |
+
const crypto = require('crypto');
|
| 486 |
+
const { password, confirmPassword } = req.body;
|
| 487 |
+
const { token } = req.params;
|
| 488 |
+
|
| 489 |
+
if (!password || !confirmPassword) {
|
| 490 |
+
return res
|
| 491 |
+
.status(400)
|
| 492 |
+
.json({ message: 'Please provide password and confirm password' });
|
| 493 |
+
}
|
| 494 |
+
|
| 495 |
+
if (password !== confirmPassword) {
|
| 496 |
+
return res.status(400).json({ message: 'Passwords do not match' });
|
| 497 |
+
}
|
| 498 |
+
|
| 499 |
+
if (password.length < 6) {
|
| 500 |
+
return res
|
| 501 |
+
.status(400)
|
| 502 |
+
.json({ message: 'Password must be at least 6 characters' });
|
| 503 |
+
}
|
| 504 |
+
|
| 505 |
+
const hashedToken = crypto.createHash('sha256').update(token).digest('hex');
|
| 506 |
+
|
| 507 |
+
const user = await User.findOne({
|
| 508 |
+
passwordResetToken: hashedToken,
|
| 509 |
+
passwordResetExpires: { $gt: Date.now() },
|
| 510 |
+
});
|
| 511 |
+
|
| 512 |
+
if (!user) {
|
| 513 |
+
return res.status(400).json({
|
| 514 |
+
message:
|
| 515 |
+
'Invalid or expired reset token. Please request a new password reset.',
|
| 516 |
+
});
|
| 517 |
+
}
|
| 518 |
+
|
| 519 |
+
user.password = password;
|
| 520 |
+
user.passwordResetToken = undefined;
|
| 521 |
+
user.passwordResetExpires = undefined;
|
| 522 |
+
await user.save();
|
| 523 |
+
|
| 524 |
+
const jwtToken = signToken(user._id);
|
| 525 |
+
|
| 526 |
+
res.status(200).json({
|
| 527 |
+
status: 'success',
|
| 528 |
+
message: 'Password reset successful',
|
| 529 |
+
token: jwtToken,
|
| 530 |
+
});
|
| 531 |
+
} catch (err) {
|
| 532 |
+
res.status(500).json({
|
| 533 |
+
message: 'Failed to reset password',
|
| 534 |
+
error: err.message,
|
| 535 |
+
});
|
| 536 |
+
}
|
| 537 |
+
};
|
controllers/brandController.js
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const Brand = require('../models/brandModel');
|
| 2 |
+
const { deleteImage, getPublicIdFromUrl } = require('../config/cloudinary');
|
| 3 |
+
|
| 4 |
+
// Get all brands
|
| 5 |
+
exports.getAllBrands = async (req, res) => {
|
| 6 |
+
try {
|
| 7 |
+
const brands = await Brand.find().lean();
|
| 8 |
+
res.status(200).json({
|
| 9 |
+
status: 'success',
|
| 10 |
+
results: brands.length,
|
| 11 |
+
data: {
|
| 12 |
+
brands,
|
| 13 |
+
},
|
| 14 |
+
});
|
| 15 |
+
} catch (err) {
|
| 16 |
+
res.status(500).json({
|
| 17 |
+
status: 'fail',
|
| 18 |
+
message: err.message,
|
| 19 |
+
});
|
| 20 |
+
}
|
| 21 |
+
};
|
| 22 |
+
|
| 23 |
+
// Get single brand
|
| 24 |
+
exports.getBrand = async (req, res) => {
|
| 25 |
+
try {
|
| 26 |
+
const brand = await Brand.findById(req.params.id).lean();
|
| 27 |
+
if (!brand) {
|
| 28 |
+
return res.status(404).json({
|
| 29 |
+
status: 'fail',
|
| 30 |
+
message: 'No brand found with that ID',
|
| 31 |
+
});
|
| 32 |
+
}
|
| 33 |
+
res.status(200).json({
|
| 34 |
+
status: 'success',
|
| 35 |
+
data: { brand },
|
| 36 |
+
});
|
| 37 |
+
} catch (err) {
|
| 38 |
+
res.status(400).json({
|
| 39 |
+
status: 'fail',
|
| 40 |
+
message: err.message,
|
| 41 |
+
});
|
| 42 |
+
}
|
| 43 |
+
};
|
| 44 |
+
|
| 45 |
+
// Create a new brand
|
| 46 |
+
exports.createBrand = async (req, res) => {
|
| 47 |
+
try {
|
| 48 |
+
const lastBrand = await Brand.findOne().sort({ id: -1 });
|
| 49 |
+
const autoId = lastBrand ? lastBrand.id + 1 : 1;
|
| 50 |
+
|
| 51 |
+
const brandData = { ...req.body, id: req.body.id || autoId };
|
| 52 |
+
if (req.file) {
|
| 53 |
+
brandData.logo = req.file.path;
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
const newBrand = await Brand.create(brandData);
|
| 57 |
+
|
| 58 |
+
res.status(201).json({
|
| 59 |
+
status: 'success',
|
| 60 |
+
data: { brand: newBrand },
|
| 61 |
+
});
|
| 62 |
+
} catch (err) {
|
| 63 |
+
res.status(400).json({
|
| 64 |
+
status: 'fail',
|
| 65 |
+
message: err.message,
|
| 66 |
+
});
|
| 67 |
+
}
|
| 68 |
+
};
|
| 69 |
+
|
| 70 |
+
// Update a brand
|
| 71 |
+
exports.updateBrand = async (req, res) => {
|
| 72 |
+
try {
|
| 73 |
+
const brandId = req.params.id;
|
| 74 |
+
const oldBrand = await Brand.findById(brandId);
|
| 75 |
+
|
| 76 |
+
if (!oldBrand) {
|
| 77 |
+
return res.status(404).json({
|
| 78 |
+
status: 'fail',
|
| 79 |
+
message: 'No brand found with that ID',
|
| 80 |
+
});
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
const updateData = { ...req.body };
|
| 84 |
+
|
| 85 |
+
// Handle logo update or deletion
|
| 86 |
+
if (req.file) {
|
| 87 |
+
// New logo uploaded
|
| 88 |
+
updateData.logo = req.file.path;
|
| 89 |
+
|
| 90 |
+
// Delete old logo from Cloudinary if it exists
|
| 91 |
+
if (oldBrand.logo) {
|
| 92 |
+
const publicId = getPublicIdFromUrl(oldBrand.logo);
|
| 93 |
+
if (publicId)
|
| 94 |
+
await deleteImage(publicId).catch((err) =>
|
| 95 |
+
console.error('Cloudinary delete error:', err),
|
| 96 |
+
);
|
| 97 |
+
}
|
| 98 |
+
} else if (req.body.logo === '') {
|
| 99 |
+
// Logo explicitly removed
|
| 100 |
+
updateData.logo = '';
|
| 101 |
+
|
| 102 |
+
// Delete old logo from Cloudinary if it exists
|
| 103 |
+
if (oldBrand.logo) {
|
| 104 |
+
const publicId = getPublicIdFromUrl(oldBrand.logo);
|
| 105 |
+
if (publicId)
|
| 106 |
+
await deleteImage(publicId).catch((err) =>
|
| 107 |
+
console.error('Cloudinary delete error:', err),
|
| 108 |
+
);
|
| 109 |
+
}
|
| 110 |
+
} else {
|
| 111 |
+
// Keep existing logo (don't let spreading req.body over oldBrand overwrite it with undefined)
|
| 112 |
+
delete updateData.logo;
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
const updatedBrand = await Brand.findByIdAndUpdate(brandId, updateData, {
|
| 116 |
+
new: true,
|
| 117 |
+
runValidators: true,
|
| 118 |
+
});
|
| 119 |
+
|
| 120 |
+
res.status(200).json({
|
| 121 |
+
status: 'success',
|
| 122 |
+
data: { brand: updatedBrand },
|
| 123 |
+
});
|
| 124 |
+
} catch (err) {
|
| 125 |
+
res.status(400).json({
|
| 126 |
+
status: 'fail',
|
| 127 |
+
message: err.message,
|
| 128 |
+
});
|
| 129 |
+
}
|
| 130 |
+
};
|
| 131 |
+
|
| 132 |
+
// Delete a brand
|
| 133 |
+
exports.deleteBrand = async (req, res) => {
|
| 134 |
+
try {
|
| 135 |
+
const brand = await Brand.findById(req.params.id);
|
| 136 |
+
|
| 137 |
+
if (!brand) {
|
| 138 |
+
return res.status(404).json({
|
| 139 |
+
status: 'fail',
|
| 140 |
+
message: 'No brand found with that ID',
|
| 141 |
+
});
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
// Delete logo from Cloudinary if it exists
|
| 145 |
+
if (brand.logo) {
|
| 146 |
+
const publicId = getPublicIdFromUrl(brand.logo);
|
| 147 |
+
if (publicId) await deleteImage(publicId);
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
await Brand.findByIdAndDelete(req.params.id);
|
| 151 |
+
|
| 152 |
+
res.status(204).json({
|
| 153 |
+
status: 'success',
|
| 154 |
+
data: null,
|
| 155 |
+
});
|
| 156 |
+
} catch (err) {
|
| 157 |
+
res.status(400).json({
|
| 158 |
+
status: 'fail',
|
| 159 |
+
message: err.message,
|
| 160 |
+
});
|
| 161 |
+
}
|
| 162 |
+
};
|
controllers/cartController.js
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const Cart = require('../models/cartModel');
|
| 2 |
+
const Product = require('../models/productModel');
|
| 3 |
+
// add to cart function
|
| 4 |
+
exports.addToCart = async (req, res) => {
|
| 5 |
+
try {
|
| 6 |
+
const userId = req.user.id;
|
| 7 |
+
const { productId, quantity } = req.body;
|
| 8 |
+
|
| 9 |
+
const product = await Product.findById(productId);
|
| 10 |
+
if (!product) {
|
| 11 |
+
return res.status(404).json({ message: 'Product not found' });
|
| 12 |
+
}
|
| 13 |
+
let cart = await Cart.findOne({ user: userId });
|
| 14 |
+
|
| 15 |
+
if (!cart) {
|
| 16 |
+
cart = await Cart.create({
|
| 17 |
+
user: userId,
|
| 18 |
+
items: [{ product: productId, quantity }],
|
| 19 |
+
});
|
| 20 |
+
} else {
|
| 21 |
+
const existingItem = cart.items.find(
|
| 22 |
+
(item) => item.product.toString() === productId,
|
| 23 |
+
);
|
| 24 |
+
|
| 25 |
+
if (existingItem) {
|
| 26 |
+
existingItem.quantity += quantity;
|
| 27 |
+
} else {
|
| 28 |
+
cart.items.push({ product: productId, quantity });
|
| 29 |
+
}
|
| 30 |
+
await cart.save();
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
// Populate product details before returning
|
| 34 |
+
await cart.populate({
|
| 35 |
+
path: 'items.product',
|
| 36 |
+
select: 'nameAr nameEn price salePrice imageCover stock',
|
| 37 |
+
});
|
| 38 |
+
|
| 39 |
+
res.status(200).json({
|
| 40 |
+
status: 'success',
|
| 41 |
+
message: 'Product added to cart',
|
| 42 |
+
cart,
|
| 43 |
+
});
|
| 44 |
+
} catch (err) {
|
| 45 |
+
res
|
| 46 |
+
.status(500)
|
| 47 |
+
.json({ message: 'Failed to add product', error: err.message });
|
| 48 |
+
}
|
| 49 |
+
};
|
| 50 |
+
|
| 51 |
+
exports.getCart = async (req, res) => {
|
| 52 |
+
try {
|
| 53 |
+
const userId = req.user.id;
|
| 54 |
+
|
| 55 |
+
// Find the cart associated with the user
|
| 56 |
+
const cart = await Cart.findOne({ user: userId }).populate({
|
| 57 |
+
path: 'items.product',
|
| 58 |
+
select: 'nameAr nameEn price salePrice imageCover',
|
| 59 |
+
});
|
| 60 |
+
|
| 61 |
+
if (!cart || cart.items.length === 0) {
|
| 62 |
+
return res.status(200).json({
|
| 63 |
+
status: 'success',
|
| 64 |
+
message: 'Cart is empty',
|
| 65 |
+
cart: { items: [] },
|
| 66 |
+
});
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
// Calculate the total price
|
| 70 |
+
const totalPrice = cart.items.reduce((acc, item) => {
|
| 71 |
+
const price = item.product.salePrice || item.product.price;
|
| 72 |
+
return acc + price * item.quantity;
|
| 73 |
+
}, 0);
|
| 74 |
+
|
| 75 |
+
res.status(200).json({
|
| 76 |
+
status: 'success',
|
| 77 |
+
cart,
|
| 78 |
+
totalPrice,
|
| 79 |
+
});
|
| 80 |
+
} catch (err) {
|
| 81 |
+
res
|
| 82 |
+
.status(500)
|
| 83 |
+
.json({ message: 'Failed to fetch cart', error: err.message });
|
| 84 |
+
}
|
| 85 |
+
};
|
| 86 |
+
|
| 87 |
+
// PATCH /samoulla/v1/cart/update-quantity
|
| 88 |
+
exports.updateQuantity = async (req, res) => {
|
| 89 |
+
try {
|
| 90 |
+
const userId = req.user.id;
|
| 91 |
+
const { productId, action } = req.body;
|
| 92 |
+
|
| 93 |
+
const cart = await Cart.findOne({ user: userId }).populate({
|
| 94 |
+
path: 'items.product',
|
| 95 |
+
select: 'nameAr nameEn price salePrice imageCover',
|
| 96 |
+
});
|
| 97 |
+
|
| 98 |
+
if (!cart) {
|
| 99 |
+
return res.status(404).json({ message: 'Cart not found' });
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
const cartItem = cart.items.find(
|
| 103 |
+
(el) => el.product._id.toString() === productId,
|
| 104 |
+
);
|
| 105 |
+
|
| 106 |
+
if (!cartItem) {
|
| 107 |
+
return res.status(404).json({ message: 'Product not found in cart' });
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
if (action === 'increase') {
|
| 111 |
+
cartItem.quantity += 1;
|
| 112 |
+
} else if (action === 'decrease') {
|
| 113 |
+
cartItem.quantity -= 1;
|
| 114 |
+
|
| 115 |
+
if (cartItem.quantity <= 0) {
|
| 116 |
+
cart.items = cart.items.filter(
|
| 117 |
+
(el) => el.product._id.toString() !== productId,
|
| 118 |
+
);
|
| 119 |
+
}
|
| 120 |
+
} else {
|
| 121 |
+
return res.status(400).json({ message: 'Invalid action' });
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
await cart.save();
|
| 125 |
+
|
| 126 |
+
const totalPrice = cart.items.reduce((acc, item) => {
|
| 127 |
+
const price = item.product.salePrice || item.product.price;
|
| 128 |
+
return acc + price * item.quantity;
|
| 129 |
+
}, 0);
|
| 130 |
+
|
| 131 |
+
res.status(200).json({
|
| 132 |
+
status: 'success',
|
| 133 |
+
cart,
|
| 134 |
+
totalPrice,
|
| 135 |
+
});
|
| 136 |
+
} catch (err) {
|
| 137 |
+
res
|
| 138 |
+
.status(500)
|
| 139 |
+
.json({ message: 'Error updating quantity', error: err.message });
|
| 140 |
+
}
|
| 141 |
+
};
|
| 142 |
+
|
| 143 |
+
// PATCH /samoulla/v1/cart/update-quantity
|
| 144 |
+
// {
|
| 145 |
+
// "productId": "ID_OF_PRODUCT",
|
| 146 |
+
// "action": "increase"
|
| 147 |
+
// }
|
| 148 |
+
// {
|
| 149 |
+
// "productId": "ID_OF_PRODUCT",
|
| 150 |
+
// "action": "decrease"
|
| 151 |
+
// }
|
| 152 |
+
|
| 153 |
+
// DELETE /samoulla/v1/cart/remove-item
|
| 154 |
+
exports.removeFromCart = async (req, res) => {
|
| 155 |
+
try {
|
| 156 |
+
const userId = req.user.id;
|
| 157 |
+
const { productId } = req.params;
|
| 158 |
+
|
| 159 |
+
const cart = await Cart.findOne({ user: userId }).populate({
|
| 160 |
+
path: 'items.product',
|
| 161 |
+
select: 'nameAr nameEn price salePrice imageCover',
|
| 162 |
+
});
|
| 163 |
+
|
| 164 |
+
if (!cart) {
|
| 165 |
+
return res.status(404).json({ message: 'Cart not found' });
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
const itemExists = cart.items.find(
|
| 169 |
+
(el) => el.product._id.toString() === productId,
|
| 170 |
+
);
|
| 171 |
+
|
| 172 |
+
if (!itemExists) {
|
| 173 |
+
return res.status(404).json({ message: 'Product not found in cart' });
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
cart.items = cart.items.filter(
|
| 177 |
+
(el) => el.product._id.toString() !== productId,
|
| 178 |
+
);
|
| 179 |
+
|
| 180 |
+
await cart.save();
|
| 181 |
+
|
| 182 |
+
const totalPrice = cart.items.reduce((acc, item) => {
|
| 183 |
+
const price = item.product.salePrice || item.product.price;
|
| 184 |
+
return acc + price * item.quantity;
|
| 185 |
+
}, 0);
|
| 186 |
+
|
| 187 |
+
res.status(200).json({
|
| 188 |
+
status: 'success',
|
| 189 |
+
message: 'Product removed from cart',
|
| 190 |
+
cart,
|
| 191 |
+
totalPrice,
|
| 192 |
+
});
|
| 193 |
+
} catch (err) {
|
| 194 |
+
res
|
| 195 |
+
.status(500)
|
| 196 |
+
.json({ message: 'Error removing product', error: err.message });
|
| 197 |
+
}
|
| 198 |
+
};
|
| 199 |
+
|
| 200 |
+
exports.clearCart = async (req, res) => {
|
| 201 |
+
try {
|
| 202 |
+
const cart = await Cart.findOne({ user: req.user._id });
|
| 203 |
+
|
| 204 |
+
if (!cart) {
|
| 205 |
+
return res.status(404).json({ message: 'Cart not found' });
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
cart.items = [];
|
| 209 |
+
await cart.save();
|
| 210 |
+
|
| 211 |
+
res.status(200).json({
|
| 212 |
+
status: 'success',
|
| 213 |
+
message: 'Cart cleared successfully',
|
| 214 |
+
cart,
|
| 215 |
+
});
|
| 216 |
+
} catch (err) {
|
| 217 |
+
res
|
| 218 |
+
.status(500)
|
| 219 |
+
.json({ message: 'Something went wrong', error: err.message });
|
| 220 |
+
}
|
| 221 |
+
};
|
| 222 |
+
|
| 223 |
+
// GET /samoulla/v1/cart/all (Admin only)
|
| 224 |
+
exports.getAllCarts = async (req, res) => {
|
| 225 |
+
try {
|
| 226 |
+
// Find all carts that are not empty and belong to a user
|
| 227 |
+
const carts = await Cart.find({
|
| 228 |
+
user: { $exists: true, $ne: null },
|
| 229 |
+
items: { $exists: true, $not: { $size: 0 } },
|
| 230 |
+
})
|
| 231 |
+
.populate({
|
| 232 |
+
path: 'user',
|
| 233 |
+
select: 'name email phone',
|
| 234 |
+
})
|
| 235 |
+
.populate({
|
| 236 |
+
path: 'items.product',
|
| 237 |
+
select: 'nameAr nameEn price salePrice imageCover',
|
| 238 |
+
})
|
| 239 |
+
.sort('-updatedAt');
|
| 240 |
+
|
| 241 |
+
res.status(200).json({
|
| 242 |
+
status: 'success',
|
| 243 |
+
results: carts.length,
|
| 244 |
+
data: {
|
| 245 |
+
carts,
|
| 246 |
+
},
|
| 247 |
+
});
|
| 248 |
+
} catch (err) {
|
| 249 |
+
res.status(500).json({
|
| 250 |
+
status: 'error',
|
| 251 |
+
message: 'Failed to fetch carts',
|
| 252 |
+
error: err.message,
|
| 253 |
+
});
|
| 254 |
+
}
|
| 255 |
+
};
|
| 256 |
+
// DELETE /samoulla/v1/cart/:id (Admin only)
|
| 257 |
+
exports.deleteCart = async (req, res) => {
|
| 258 |
+
try {
|
| 259 |
+
const cart = await Cart.findByIdAndDelete(req.params.id);
|
| 260 |
+
|
| 261 |
+
if (!cart) {
|
| 262 |
+
return res.status(404).json({
|
| 263 |
+
status: 'fail',
|
| 264 |
+
message: 'No cart found with that ID',
|
| 265 |
+
});
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
res.status(204).json({
|
| 269 |
+
status: 'success',
|
| 270 |
+
data: null,
|
| 271 |
+
});
|
| 272 |
+
} catch (err) {
|
| 273 |
+
res.status(500).json({
|
| 274 |
+
status: 'error',
|
| 275 |
+
message: 'Failed to delete cart',
|
| 276 |
+
error: err.message,
|
| 277 |
+
});
|
| 278 |
+
}
|
| 279 |
+
};
|
controllers/categoryController.js
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const Category = require('../models/categoryModel');
|
| 2 |
+
|
| 3 |
+
// Get all categories (optionally filter by parent to get root categories or subcategories)
|
| 4 |
+
exports.getAllCategories = async (req, res) => {
|
| 5 |
+
try {
|
| 6 |
+
const { parentId } = req.query;
|
| 7 |
+
|
| 8 |
+
// Build filter - if parentId is 'root' or null, get root categories
|
| 9 |
+
let filter = {};
|
| 10 |
+
if (parentId === 'root' || parentId === 'null') {
|
| 11 |
+
filter.parent = null;
|
| 12 |
+
} else if (parentId) {
|
| 13 |
+
filter.parent = parentId;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
const categories = await Category.find(filter)
|
| 17 |
+
.sort({ id: 1 })
|
| 18 |
+
.populate('parent', 'nameAr nameEn slug')
|
| 19 |
+
.lean();
|
| 20 |
+
res.status(200).json({
|
| 21 |
+
status: 'success',
|
| 22 |
+
results: categories.length,
|
| 23 |
+
data: {
|
| 24 |
+
categories,
|
| 25 |
+
},
|
| 26 |
+
});
|
| 27 |
+
} catch (err) {
|
| 28 |
+
res.status(500).json({
|
| 29 |
+
status: 'fail',
|
| 30 |
+
message: err.message,
|
| 31 |
+
});
|
| 32 |
+
}
|
| 33 |
+
};
|
| 34 |
+
|
| 35 |
+
// Get single category with parent and children
|
| 36 |
+
exports.getCategory = async (req, res) => {
|
| 37 |
+
try {
|
| 38 |
+
const category = await Category.findById(req.params.id)
|
| 39 |
+
.populate('parent', 'nameAr nameEn slug')
|
| 40 |
+
.populate('children', 'nameAr nameEn slug level')
|
| 41 |
+
.lean();
|
| 42 |
+
if (!category) {
|
| 43 |
+
return res.status(404).json({
|
| 44 |
+
status: 'fail',
|
| 45 |
+
message: 'No category found with that ID',
|
| 46 |
+
});
|
| 47 |
+
}
|
| 48 |
+
res.status(200).json({
|
| 49 |
+
status: 'success',
|
| 50 |
+
data: { category },
|
| 51 |
+
});
|
| 52 |
+
} catch (err) {
|
| 53 |
+
res.status(400).json({
|
| 54 |
+
status: 'fail',
|
| 55 |
+
message: err.message,
|
| 56 |
+
});
|
| 57 |
+
}
|
| 58 |
+
};
|
| 59 |
+
|
| 60 |
+
// Create a new category
|
| 61 |
+
exports.createCategory = async (req, res) => {
|
| 62 |
+
try {
|
| 63 |
+
const lastCategory = await Category.findOne().sort({ id: -1 });
|
| 64 |
+
const autoId = lastCategory ? lastCategory.id + 1 : 1;
|
| 65 |
+
|
| 66 |
+
const newCategory = await Category.create({
|
| 67 |
+
// eslint-disable-next-line node/no-unsupported-features/es-syntax
|
| 68 |
+
...req.body,
|
| 69 |
+
id: req.body.id || autoId,
|
| 70 |
+
});
|
| 71 |
+
|
| 72 |
+
res.status(201).json({
|
| 73 |
+
status: 'success',
|
| 74 |
+
data: { category: newCategory },
|
| 75 |
+
});
|
| 76 |
+
} catch (err) {
|
| 77 |
+
res.status(400).json({
|
| 78 |
+
status: 'fail',
|
| 79 |
+
message: err.message,
|
| 80 |
+
});
|
| 81 |
+
}
|
| 82 |
+
};
|
| 83 |
+
|
| 84 |
+
// Update a category
|
| 85 |
+
exports.updateCategory = async (req, res) => {
|
| 86 |
+
try {
|
| 87 |
+
const updatedCategory = await Category.findByIdAndUpdate(
|
| 88 |
+
req.params.id,
|
| 89 |
+
req.body,
|
| 90 |
+
{ new: true, runValidators: true },
|
| 91 |
+
);
|
| 92 |
+
if (!updatedCategory) {
|
| 93 |
+
return res.status(404).json({
|
| 94 |
+
status: 'fail',
|
| 95 |
+
message: 'No category found with that ID',
|
| 96 |
+
});
|
| 97 |
+
}
|
| 98 |
+
res.status(200).json({
|
| 99 |
+
status: 'success',
|
| 100 |
+
data: { category: updatedCategory },
|
| 101 |
+
});
|
| 102 |
+
} catch (err) {
|
| 103 |
+
res.status(400).json({
|
| 104 |
+
status: 'fail',
|
| 105 |
+
message: err.message,
|
| 106 |
+
});
|
| 107 |
+
}
|
| 108 |
+
};
|
| 109 |
+
|
| 110 |
+
// Delete a category
|
| 111 |
+
exports.deleteCategory = async (req, res) => {
|
| 112 |
+
try {
|
| 113 |
+
// First check if category has children
|
| 114 |
+
const childrenCount = await Category.countDocuments({
|
| 115 |
+
parent: req.params.id,
|
| 116 |
+
});
|
| 117 |
+
if (childrenCount > 0) {
|
| 118 |
+
return res.status(400).json({
|
| 119 |
+
status: 'fail',
|
| 120 |
+
message:
|
| 121 |
+
'Cannot delete category with subcategories. Delete subcategories first.',
|
| 122 |
+
});
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
const category = await Category.findByIdAndDelete(req.params.id);
|
| 126 |
+
if (!category) {
|
| 127 |
+
return res.status(404).json({
|
| 128 |
+
status: 'fail',
|
| 129 |
+
message: 'No category found with that ID',
|
| 130 |
+
});
|
| 131 |
+
}
|
| 132 |
+
res.status(204).json({
|
| 133 |
+
status: 'success',
|
| 134 |
+
data: null,
|
| 135 |
+
});
|
| 136 |
+
} catch (err) {
|
| 137 |
+
res.status(400).json({
|
| 138 |
+
status: 'fail',
|
| 139 |
+
message: err.message,
|
| 140 |
+
});
|
| 141 |
+
}
|
| 142 |
+
};
|
| 143 |
+
|
| 144 |
+
// Get all subcategories of a specific category
|
| 145 |
+
exports.getSubcategories = async (req, res) => {
|
| 146 |
+
try {
|
| 147 |
+
const subcategories = await Category.find({ parent: req.params.id }).sort({ id: 1 }).lean();
|
| 148 |
+
res.status(200).json({
|
| 149 |
+
status: 'success',
|
| 150 |
+
results: subcategories.length,
|
| 151 |
+
data: {
|
| 152 |
+
subcategories,
|
| 153 |
+
},
|
| 154 |
+
});
|
| 155 |
+
} catch (err) {
|
| 156 |
+
res.status(400).json({
|
| 157 |
+
status: 'fail',
|
| 158 |
+
message: err.message,
|
| 159 |
+
});
|
| 160 |
+
}
|
| 161 |
+
};
|
| 162 |
+
|
| 163 |
+
// Get full category tree (hierarchical structure)
|
| 164 |
+
exports.getCategoryTree = async (req, res) => {
|
| 165 |
+
try {
|
| 166 |
+
// Fetch all categories once to build tree in memory
|
| 167 |
+
const allCategories = await Category.find().sort({ id: 1 }).lean();
|
| 168 |
+
|
| 169 |
+
const categoryMap = {};
|
| 170 |
+
allCategories.forEach((cat) => {
|
| 171 |
+
categoryMap[cat._id.toString()] = { ...cat, children: [] };
|
| 172 |
+
});
|
| 173 |
+
|
| 174 |
+
const tree = [];
|
| 175 |
+
allCategories.forEach((cat) => {
|
| 176 |
+
const catObj = categoryMap[cat._id.toString()];
|
| 177 |
+
if (cat.parent) {
|
| 178 |
+
const parentId = cat.parent.toString();
|
| 179 |
+
if (categoryMap[parentId]) {
|
| 180 |
+
categoryMap[parentId].children.push(catObj);
|
| 181 |
+
}
|
| 182 |
+
} else {
|
| 183 |
+
tree.push(catObj);
|
| 184 |
+
}
|
| 185 |
+
});
|
| 186 |
+
|
| 187 |
+
// Sort root categories and children by numeric id
|
| 188 |
+
tree.sort((a, b) => a.id - b.id);
|
| 189 |
+
tree.forEach((cat) => {
|
| 190 |
+
if (cat.children && cat.children.length > 0) {
|
| 191 |
+
cat.children.sort((a, b) => a.id - b.id);
|
| 192 |
+
}
|
| 193 |
+
});
|
| 194 |
+
|
| 195 |
+
res.status(200).json({
|
| 196 |
+
status: 'success',
|
| 197 |
+
results: tree.length,
|
| 198 |
+
data: {
|
| 199 |
+
categories: tree,
|
| 200 |
+
},
|
| 201 |
+
});
|
| 202 |
+
} catch (err) {
|
| 203 |
+
res.status(500).json({
|
| 204 |
+
status: 'fail',
|
| 205 |
+
message: err.message,
|
| 206 |
+
});
|
| 207 |
+
}
|
| 208 |
+
};
|
controllers/contentController.js
ADDED
|
@@ -0,0 +1,705 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const HeroSlide = require('../models/heroSlideModel');
|
| 2 |
+
const SiteSettings = require('../models/siteSettingsModel');
|
| 3 |
+
const AnnouncementBar = require('../models/announcementBarModel');
|
| 4 |
+
const SalesBanner = require('../models/salesBannerModel');
|
| 5 |
+
const FeaturedBanner = require('../models/featuredBannerModel');
|
| 6 |
+
const WhyChooseUs = require('../models/whyChooseUsModel');
|
| 7 |
+
|
| 8 |
+
// ============================================
|
| 9 |
+
// HERO SLIDES SECTION
|
| 10 |
+
// ============================================
|
| 11 |
+
|
| 12 |
+
/**
|
| 13 |
+
* @desc Get all active hero slides (Public)
|
| 14 |
+
* @route GET /api/content/hero-slides
|
| 15 |
+
* @access Public
|
| 16 |
+
*/
|
| 17 |
+
exports.getHeroSlides = async (req, res) => {
|
| 18 |
+
try {
|
| 19 |
+
const slides = await HeroSlide.find({ isActive: true }).sort({ order: 1 });
|
| 20 |
+
|
| 21 |
+
res.status(200).json({
|
| 22 |
+
status: 'success',
|
| 23 |
+
results: slides.length,
|
| 24 |
+
data: slides,
|
| 25 |
+
});
|
| 26 |
+
} catch (error) {
|
| 27 |
+
res.status(500).json({
|
| 28 |
+
status: 'fail',
|
| 29 |
+
message: error.message,
|
| 30 |
+
});
|
| 31 |
+
}
|
| 32 |
+
};
|
| 33 |
+
|
| 34 |
+
/**
|
| 35 |
+
* @desc Get all hero slides including inactive (Admin)
|
| 36 |
+
* @route GET /api/content/hero-slides/admin/all
|
| 37 |
+
* @access Private/Admin
|
| 38 |
+
*/
|
| 39 |
+
exports.getAllHeroSlidesAdmin = async (req, res) => {
|
| 40 |
+
try {
|
| 41 |
+
const slides = await HeroSlide.find().sort({ order: 1 });
|
| 42 |
+
|
| 43 |
+
res.status(200).json({
|
| 44 |
+
status: 'success',
|
| 45 |
+
results: slides.length,
|
| 46 |
+
data: slides,
|
| 47 |
+
});
|
| 48 |
+
} catch (error) {
|
| 49 |
+
res.status(500).json({
|
| 50 |
+
status: 'fail',
|
| 51 |
+
message: error.message,
|
| 52 |
+
});
|
| 53 |
+
}
|
| 54 |
+
};
|
| 55 |
+
|
| 56 |
+
/**
|
| 57 |
+
* @desc Get single hero slide by ID
|
| 58 |
+
* @route GET /api/content/hero-slides/:id
|
| 59 |
+
* @access Public
|
| 60 |
+
*/
|
| 61 |
+
exports.getHeroSlideById = async (req, res) => {
|
| 62 |
+
try {
|
| 63 |
+
const slide = await HeroSlide.findById(req.params.id);
|
| 64 |
+
|
| 65 |
+
if (!slide) {
|
| 66 |
+
return res.status(404).json({
|
| 67 |
+
status: 'fail',
|
| 68 |
+
message: 'Slide not found',
|
| 69 |
+
});
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
res.status(200).json({
|
| 73 |
+
status: 'success',
|
| 74 |
+
data: slide,
|
| 75 |
+
});
|
| 76 |
+
} catch (error) {
|
| 77 |
+
res.status(500).json({
|
| 78 |
+
status: 'fail',
|
| 79 |
+
message: error.message,
|
| 80 |
+
});
|
| 81 |
+
}
|
| 82 |
+
};
|
| 83 |
+
|
| 84 |
+
/**
|
| 85 |
+
* @desc Create a new hero slide
|
| 86 |
+
* @route POST /api/content/hero-slides
|
| 87 |
+
* @access Private/Admin
|
| 88 |
+
*/
|
| 89 |
+
exports.createHeroSlide = async (req, res) => {
|
| 90 |
+
try {
|
| 91 |
+
const {
|
| 92 |
+
_id,
|
| 93 |
+
type,
|
| 94 |
+
imageSrc,
|
| 95 |
+
videoSrc,
|
| 96 |
+
poster,
|
| 97 |
+
title,
|
| 98 |
+
description,
|
| 99 |
+
buttonText,
|
| 100 |
+
buttonLink,
|
| 101 |
+
order,
|
| 102 |
+
isActive,
|
| 103 |
+
} = req.body;
|
| 104 |
+
|
| 105 |
+
const slide = await HeroSlide.create({
|
| 106 |
+
_id,
|
| 107 |
+
type,
|
| 108 |
+
imageSrc,
|
| 109 |
+
videoSrc,
|
| 110 |
+
poster,
|
| 111 |
+
title,
|
| 112 |
+
description,
|
| 113 |
+
buttonText,
|
| 114 |
+
buttonLink,
|
| 115 |
+
order: order || 0,
|
| 116 |
+
isActive: isActive !== undefined ? isActive : true,
|
| 117 |
+
});
|
| 118 |
+
|
| 119 |
+
res.status(201).json({
|
| 120 |
+
status: 'success',
|
| 121 |
+
data: slide,
|
| 122 |
+
});
|
| 123 |
+
} catch (error) {
|
| 124 |
+
res.status(400).json({
|
| 125 |
+
status: 'fail',
|
| 126 |
+
message: error.message,
|
| 127 |
+
});
|
| 128 |
+
}
|
| 129 |
+
};
|
| 130 |
+
|
| 131 |
+
/**
|
| 132 |
+
* @desc Update a hero slide
|
| 133 |
+
* @route PUT /api/content/hero-slides/:id
|
| 134 |
+
* @access Private/Admin
|
| 135 |
+
*/
|
| 136 |
+
exports.updateHeroSlide = async (req, res) => {
|
| 137 |
+
try {
|
| 138 |
+
const {
|
| 139 |
+
type,
|
| 140 |
+
imageSrc,
|
| 141 |
+
videoSrc,
|
| 142 |
+
poster,
|
| 143 |
+
title,
|
| 144 |
+
description,
|
| 145 |
+
buttonText,
|
| 146 |
+
buttonLink,
|
| 147 |
+
order,
|
| 148 |
+
isActive,
|
| 149 |
+
} = req.body;
|
| 150 |
+
|
| 151 |
+
const slide = await HeroSlide.findById(req.params.id);
|
| 152 |
+
|
| 153 |
+
if (!slide) {
|
| 154 |
+
return res.status(404).json({
|
| 155 |
+
status: 'fail',
|
| 156 |
+
message: 'Slide not found',
|
| 157 |
+
});
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
// Update fields
|
| 161 |
+
slide.type = type || slide.type;
|
| 162 |
+
slide.imageSrc = imageSrc !== undefined ? imageSrc : slide.imageSrc;
|
| 163 |
+
slide.videoSrc = videoSrc !== undefined ? videoSrc : slide.videoSrc;
|
| 164 |
+
slide.poster = poster !== undefined ? poster : slide.poster;
|
| 165 |
+
slide.title = title !== undefined ? title : slide.title;
|
| 166 |
+
slide.description =
|
| 167 |
+
description !== undefined ? description : slide.description;
|
| 168 |
+
slide.buttonText = buttonText !== undefined ? buttonText : slide.buttonText;
|
| 169 |
+
slide.buttonLink = buttonLink !== undefined ? buttonLink : slide.buttonLink;
|
| 170 |
+
slide.order = order !== undefined ? order : slide.order;
|
| 171 |
+
slide.isActive = isActive !== undefined ? isActive : slide.isActive;
|
| 172 |
+
|
| 173 |
+
const updatedSlide = await slide.save();
|
| 174 |
+
|
| 175 |
+
res.status(200).json({
|
| 176 |
+
status: 'success',
|
| 177 |
+
data: updatedSlide,
|
| 178 |
+
});
|
| 179 |
+
} catch (error) {
|
| 180 |
+
res.status(400).json({
|
| 181 |
+
status: 'fail',
|
| 182 |
+
message: error.message,
|
| 183 |
+
});
|
| 184 |
+
}
|
| 185 |
+
};
|
| 186 |
+
|
| 187 |
+
/**
|
| 188 |
+
* @desc Delete a hero slide
|
| 189 |
+
* @route DELETE /api/content/hero-slides/:id
|
| 190 |
+
* @access Private/Admin
|
| 191 |
+
*/
|
| 192 |
+
exports.deleteHeroSlide = async (req, res) => {
|
| 193 |
+
try {
|
| 194 |
+
const slide = await HeroSlide.findById(req.params.id);
|
| 195 |
+
|
| 196 |
+
if (!slide) {
|
| 197 |
+
return res.status(404).json({
|
| 198 |
+
status: 'fail',
|
| 199 |
+
message: 'Slide not found',
|
| 200 |
+
});
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
await HeroSlide.findByIdAndDelete(req.params.id);
|
| 204 |
+
|
| 205 |
+
res.status(200).json({
|
| 206 |
+
status: 'success',
|
| 207 |
+
message: 'Slide deleted successfully',
|
| 208 |
+
});
|
| 209 |
+
} catch (error) {
|
| 210 |
+
res.status(500).json({
|
| 211 |
+
status: 'fail',
|
| 212 |
+
message: error.message,
|
| 213 |
+
});
|
| 214 |
+
}
|
| 215 |
+
};
|
| 216 |
+
|
| 217 |
+
// ============================================
|
| 218 |
+
// SITE SETTINGS SECTION
|
| 219 |
+
// ============================================
|
| 220 |
+
|
| 221 |
+
/**
|
| 222 |
+
* @desc Get site settings
|
| 223 |
+
* @route GET /api/content/settings
|
| 224 |
+
* @access Public
|
| 225 |
+
*/
|
| 226 |
+
exports.getSiteSettings = async (req, res) => {
|
| 227 |
+
try {
|
| 228 |
+
const settings = await SiteSettings.getSettings();
|
| 229 |
+
|
| 230 |
+
res.status(200).json({
|
| 231 |
+
status: 'success',
|
| 232 |
+
data: settings,
|
| 233 |
+
});
|
| 234 |
+
} catch (error) {
|
| 235 |
+
res.status(500).json({
|
| 236 |
+
status: 'fail',
|
| 237 |
+
message: error.message,
|
| 238 |
+
});
|
| 239 |
+
}
|
| 240 |
+
};
|
| 241 |
+
|
| 242 |
+
// ============================================
|
| 243 |
+
// ANNOUNCEMENT BAR SECTION
|
| 244 |
+
// ============================================
|
| 245 |
+
|
| 246 |
+
/**
|
| 247 |
+
* @desc Get announcement bar settings
|
| 248 |
+
* @route GET /api/content/announcement-bar
|
| 249 |
+
* @access Public
|
| 250 |
+
*/
|
| 251 |
+
exports.getAnnouncementBar = async (req, res) => {
|
| 252 |
+
try {
|
| 253 |
+
const settings = await AnnouncementBar.getSettings();
|
| 254 |
+
|
| 255 |
+
res.status(200).json({
|
| 256 |
+
status: 'success',
|
| 257 |
+
data: settings,
|
| 258 |
+
});
|
| 259 |
+
} catch (error) {
|
| 260 |
+
console.error('ERROR in getAnnouncementBar:', error);
|
| 261 |
+
res.status(500).json({
|
| 262 |
+
status: 'fail',
|
| 263 |
+
message: error.message,
|
| 264 |
+
});
|
| 265 |
+
}
|
| 266 |
+
};
|
| 267 |
+
|
| 268 |
+
/**
|
| 269 |
+
* @desc Update announcement bar settings
|
| 270 |
+
* @route PUT /api/content/announcement-bar
|
| 271 |
+
* @access Private/Admin
|
| 272 |
+
*/
|
| 273 |
+
exports.updateAnnouncementBar = async (req, res) => {
|
| 274 |
+
try {
|
| 275 |
+
const settings = await AnnouncementBar.getSettings();
|
| 276 |
+
|
| 277 |
+
// Update permitted fields
|
| 278 |
+
const restrictedFields = ['_id', '__v', 'createdAt', 'updatedAt'];
|
| 279 |
+
Object.keys(req.body).forEach((key) => {
|
| 280 |
+
if (!restrictedFields.includes(key)) {
|
| 281 |
+
settings[key] = req.body[key];
|
| 282 |
+
}
|
| 283 |
+
});
|
| 284 |
+
|
| 285 |
+
const updatedSettings = await settings.save();
|
| 286 |
+
|
| 287 |
+
res.status(200).json({
|
| 288 |
+
status: 'success',
|
| 289 |
+
data: updatedSettings,
|
| 290 |
+
});
|
| 291 |
+
} catch (error) {
|
| 292 |
+
console.error('ERROR in updateAnnouncementBar:', error);
|
| 293 |
+
res.status(400).json({
|
| 294 |
+
status: 'fail',
|
| 295 |
+
message: error.message,
|
| 296 |
+
});
|
| 297 |
+
}
|
| 298 |
+
};
|
| 299 |
+
|
| 300 |
+
/**
|
| 301 |
+
* @desc Update site settings
|
| 302 |
+
* @route PUT /api/content/settings
|
| 303 |
+
* @access Private/Admin
|
| 304 |
+
*/
|
| 305 |
+
exports.updateSiteSettings = async (req, res) => {
|
| 306 |
+
try {
|
| 307 |
+
const settings = await SiteSettings.getSettings();
|
| 308 |
+
|
| 309 |
+
// Update fields if provided
|
| 310 |
+
if (req.body.topBar) {
|
| 311 |
+
settings.topBar = { ...settings.topBar, ...req.body.topBar };
|
| 312 |
+
}
|
| 313 |
+
if (req.body.footer) {
|
| 314 |
+
settings.footer = { ...settings.footer, ...req.body.footer };
|
| 315 |
+
}
|
| 316 |
+
if (req.body.legalPages) {
|
| 317 |
+
if (req.body.legalPages.termsAndConditions) {
|
| 318 |
+
settings.legalPages.termsAndConditions = {
|
| 319 |
+
...settings.legalPages.termsAndConditions,
|
| 320 |
+
...req.body.legalPages.termsAndConditions,
|
| 321 |
+
lastUpdated: Date.now(),
|
| 322 |
+
};
|
| 323 |
+
}
|
| 324 |
+
if (req.body.legalPages.privacyPolicy) {
|
| 325 |
+
settings.legalPages.privacyPolicy = {
|
| 326 |
+
...settings.legalPages.privacyPolicy,
|
| 327 |
+
...req.body.legalPages.privacyPolicy,
|
| 328 |
+
lastUpdated: Date.now(),
|
| 329 |
+
};
|
| 330 |
+
}
|
| 331 |
+
}
|
| 332 |
+
|
| 333 |
+
const updatedSettings = await settings.save();
|
| 334 |
+
|
| 335 |
+
res.status(200).json({
|
| 336 |
+
status: 'success',
|
| 337 |
+
data: updatedSettings,
|
| 338 |
+
});
|
| 339 |
+
} catch (error) {
|
| 340 |
+
res.status(400).json({
|
| 341 |
+
status: 'fail',
|
| 342 |
+
message: error.message,
|
| 343 |
+
});
|
| 344 |
+
}
|
| 345 |
+
};
|
| 346 |
+
|
| 347 |
+
// ============================================
|
| 348 |
+
// SALES BANNERS SECTION
|
| 349 |
+
// ============================================
|
| 350 |
+
|
| 351 |
+
/**
|
| 352 |
+
* @desc Get all active sales banners
|
| 353 |
+
* @route GET /api/content/sales-banners
|
| 354 |
+
* @access Public
|
| 355 |
+
*/
|
| 356 |
+
exports.getSalesBanners = async (req, res) => {
|
| 357 |
+
try {
|
| 358 |
+
const banners = await SalesBanner.find({ isActive: true }).sort({
|
| 359 |
+
order: 1,
|
| 360 |
+
});
|
| 361 |
+
|
| 362 |
+
res.status(200).json({
|
| 363 |
+
status: 'success',
|
| 364 |
+
results: banners.length,
|
| 365 |
+
data: banners,
|
| 366 |
+
});
|
| 367 |
+
} catch (error) {
|
| 368 |
+
res.status(500).json({
|
| 369 |
+
status: 'fail',
|
| 370 |
+
message: error.message,
|
| 371 |
+
});
|
| 372 |
+
}
|
| 373 |
+
};
|
| 374 |
+
|
| 375 |
+
/**
|
| 376 |
+
* @desc Get all sales banners including inactive (Admin)
|
| 377 |
+
* @route GET /api/content/sales-banners/admin/all
|
| 378 |
+
* @access Private/Admin
|
| 379 |
+
*/
|
| 380 |
+
exports.getAllSalesBannersAdmin = async (req, res) => {
|
| 381 |
+
try {
|
| 382 |
+
const banners = await SalesBanner.find().sort({ order: 1 });
|
| 383 |
+
|
| 384 |
+
res.status(200).json({
|
| 385 |
+
status: 'success',
|
| 386 |
+
results: banners.length,
|
| 387 |
+
data: banners,
|
| 388 |
+
});
|
| 389 |
+
} catch (error) {
|
| 390 |
+
res.status(500).json({
|
| 391 |
+
status: 'fail',
|
| 392 |
+
message: error.message,
|
| 393 |
+
});
|
| 394 |
+
}
|
| 395 |
+
};
|
| 396 |
+
|
| 397 |
+
/**
|
| 398 |
+
* @desc Create a new sales banner
|
| 399 |
+
* @route POST /api/content/sales-banners
|
| 400 |
+
* @access Private/Admin
|
| 401 |
+
*/
|
| 402 |
+
exports.createSalesBanner = async (req, res) => {
|
| 403 |
+
try {
|
| 404 |
+
const banner = await SalesBanner.create(req.body);
|
| 405 |
+
|
| 406 |
+
res.status(201).json({
|
| 407 |
+
status: 'success',
|
| 408 |
+
data: banner,
|
| 409 |
+
});
|
| 410 |
+
} catch (error) {
|
| 411 |
+
res.status(400).json({
|
| 412 |
+
status: 'fail',
|
| 413 |
+
message: error.message,
|
| 414 |
+
});
|
| 415 |
+
}
|
| 416 |
+
};
|
| 417 |
+
|
| 418 |
+
/**
|
| 419 |
+
* @desc Update a sales banner
|
| 420 |
+
* @route PUT /api/content/sales-banners/:id
|
| 421 |
+
* @access Private/Admin
|
| 422 |
+
*/
|
| 423 |
+
exports.updateSalesBanner = async (req, res) => {
|
| 424 |
+
try {
|
| 425 |
+
const banner = await SalesBanner.findByIdAndUpdate(
|
| 426 |
+
req.params.id,
|
| 427 |
+
req.body,
|
| 428 |
+
{
|
| 429 |
+
new: true,
|
| 430 |
+
runValidators: true,
|
| 431 |
+
},
|
| 432 |
+
);
|
| 433 |
+
|
| 434 |
+
if (!banner) {
|
| 435 |
+
return res.status(404).json({
|
| 436 |
+
status: 'fail',
|
| 437 |
+
message: 'Banner not found',
|
| 438 |
+
});
|
| 439 |
+
}
|
| 440 |
+
|
| 441 |
+
res.status(200).json({
|
| 442 |
+
status: 'success',
|
| 443 |
+
data: banner,
|
| 444 |
+
});
|
| 445 |
+
} catch (error) {
|
| 446 |
+
res.status(400).json({
|
| 447 |
+
status: 'fail',
|
| 448 |
+
message: error.message,
|
| 449 |
+
});
|
| 450 |
+
}
|
| 451 |
+
};
|
| 452 |
+
|
| 453 |
+
/**
|
| 454 |
+
* @desc Delete a sales banner
|
| 455 |
+
* @route DELETE /api/content/sales-banners/:id
|
| 456 |
+
* @access Private/Admin
|
| 457 |
+
*/
|
| 458 |
+
exports.deleteSalesBanner = async (req, res) => {
|
| 459 |
+
try {
|
| 460 |
+
const banner = await SalesBanner.findByIdAndDelete(req.params.id);
|
| 461 |
+
|
| 462 |
+
if (!banner) {
|
| 463 |
+
return res.status(404).json({
|
| 464 |
+
status: 'fail',
|
| 465 |
+
message: 'Banner not found',
|
| 466 |
+
});
|
| 467 |
+
}
|
| 468 |
+
|
| 469 |
+
res.status(200).json({
|
| 470 |
+
status: 'success',
|
| 471 |
+
message: 'Banner deleted successfully',
|
| 472 |
+
});
|
| 473 |
+
} catch (error) {
|
| 474 |
+
res.status(500).json({
|
| 475 |
+
status: 'fail',
|
| 476 |
+
message: error.message,
|
| 477 |
+
});
|
| 478 |
+
}
|
| 479 |
+
};
|
| 480 |
+
|
| 481 |
+
/**
|
| 482 |
+
* @desc Delete all sales banners
|
| 483 |
+
* @route DELETE /api/content/sales-banners
|
| 484 |
+
* @access Private/Admin
|
| 485 |
+
*/
|
| 486 |
+
exports.deleteAllSalesBanners = async (req, res) => {
|
| 487 |
+
try {
|
| 488 |
+
await SalesBanner.deleteMany({});
|
| 489 |
+
|
| 490 |
+
res.status(200).json({
|
| 491 |
+
status: 'success',
|
| 492 |
+
message: 'All banners deleted successfully',
|
| 493 |
+
});
|
| 494 |
+
} catch (error) {
|
| 495 |
+
res.status(500).json({
|
| 496 |
+
status: 'fail',
|
| 497 |
+
message: error.message,
|
| 498 |
+
});
|
| 499 |
+
}
|
| 500 |
+
};
|
| 501 |
+
// ============================================
|
| 502 |
+
// FEATURED BANNERS SECTION
|
| 503 |
+
// ============================================
|
| 504 |
+
|
| 505 |
+
/**
|
| 506 |
+
* @desc Get all active featured banners
|
| 507 |
+
* @route GET /api/content/featured-banners
|
| 508 |
+
* @access Public
|
| 509 |
+
*/
|
| 510 |
+
exports.getFeaturedBanners = async (req, res) => {
|
| 511 |
+
try {
|
| 512 |
+
const banners = await FeaturedBanner.find({ isActive: true });
|
| 513 |
+
|
| 514 |
+
res.status(200).json({
|
| 515 |
+
status: 'success',
|
| 516 |
+
results: banners.length,
|
| 517 |
+
data: banners,
|
| 518 |
+
});
|
| 519 |
+
} catch (error) {
|
| 520 |
+
res.status(500).json({
|
| 521 |
+
status: 'fail',
|
| 522 |
+
message: error.message,
|
| 523 |
+
});
|
| 524 |
+
}
|
| 525 |
+
};
|
| 526 |
+
|
| 527 |
+
/**
|
| 528 |
+
* @desc Get all featured banners including inactive (Admin)
|
| 529 |
+
* @route GET /api/content/featured-banners/admin/all
|
| 530 |
+
* @access Private/Admin
|
| 531 |
+
*/
|
| 532 |
+
exports.getAllFeaturedBannersAdmin = async (req, res) => {
|
| 533 |
+
try {
|
| 534 |
+
const banners = await FeaturedBanner.find();
|
| 535 |
+
|
| 536 |
+
res.status(200).json({
|
| 537 |
+
status: 'success',
|
| 538 |
+
results: banners.length,
|
| 539 |
+
data: banners,
|
| 540 |
+
});
|
| 541 |
+
} catch (error) {
|
| 542 |
+
res.status(500).json({
|
| 543 |
+
status: 'fail',
|
| 544 |
+
message: error.message,
|
| 545 |
+
});
|
| 546 |
+
}
|
| 547 |
+
};
|
| 548 |
+
|
| 549 |
+
/**
|
| 550 |
+
* @desc Create a new featured banner
|
| 551 |
+
* @route POST /api/content/featured-banners
|
| 552 |
+
* @access Private/Admin
|
| 553 |
+
*/
|
| 554 |
+
exports.createFeaturedBanner = async (req, res) => {
|
| 555 |
+
try {
|
| 556 |
+
const banner = await FeaturedBanner.create(req.body);
|
| 557 |
+
|
| 558 |
+
res.status(201).json({
|
| 559 |
+
status: 'success',
|
| 560 |
+
data: banner,
|
| 561 |
+
});
|
| 562 |
+
} catch (error) {
|
| 563 |
+
res.status(400).json({
|
| 564 |
+
status: 'fail',
|
| 565 |
+
message: error.message,
|
| 566 |
+
});
|
| 567 |
+
}
|
| 568 |
+
};
|
| 569 |
+
|
| 570 |
+
/**
|
| 571 |
+
* @desc Update a featured banner
|
| 572 |
+
* @route PUT /api/content/featured-banners/:id
|
| 573 |
+
* @access Private/Admin
|
| 574 |
+
*/
|
| 575 |
+
exports.updateFeaturedBanner = async (req, res) => {
|
| 576 |
+
try {
|
| 577 |
+
const banner = await FeaturedBanner.findByIdAndUpdate(
|
| 578 |
+
req.params.id,
|
| 579 |
+
req.body,
|
| 580 |
+
{
|
| 581 |
+
new: true,
|
| 582 |
+
runValidators: true,
|
| 583 |
+
},
|
| 584 |
+
);
|
| 585 |
+
|
| 586 |
+
if (!banner) {
|
| 587 |
+
return res.status(404).json({
|
| 588 |
+
status: 'fail',
|
| 589 |
+
message: 'Banner not found',
|
| 590 |
+
});
|
| 591 |
+
}
|
| 592 |
+
|
| 593 |
+
res.status(200).json({
|
| 594 |
+
status: 'success',
|
| 595 |
+
data: banner,
|
| 596 |
+
});
|
| 597 |
+
} catch (error) {
|
| 598 |
+
res.status(400).json({
|
| 599 |
+
status: 'fail',
|
| 600 |
+
message: error.message,
|
| 601 |
+
});
|
| 602 |
+
}
|
| 603 |
+
};
|
| 604 |
+
|
| 605 |
+
/**
|
| 606 |
+
* @desc Delete a featured banner
|
| 607 |
+
* @route DELETE /api/content/featured-banners/:id
|
| 608 |
+
* @access Private/Admin
|
| 609 |
+
*/
|
| 610 |
+
exports.deleteFeaturedBanner = async (req, res) => {
|
| 611 |
+
try {
|
| 612 |
+
const banner = await FeaturedBanner.findByIdAndDelete(req.params.id);
|
| 613 |
+
|
| 614 |
+
if (!banner) {
|
| 615 |
+
return res.status(404).json({
|
| 616 |
+
status: 'fail',
|
| 617 |
+
message: 'Banner not found',
|
| 618 |
+
});
|
| 619 |
+
}
|
| 620 |
+
|
| 621 |
+
res.status(200).json({
|
| 622 |
+
status: 'success',
|
| 623 |
+
message: 'Banner deleted successfully',
|
| 624 |
+
});
|
| 625 |
+
} catch (error) {
|
| 626 |
+
res.status(500).json({
|
| 627 |
+
status: 'fail',
|
| 628 |
+
message: error.message,
|
| 629 |
+
});
|
| 630 |
+
}
|
| 631 |
+
};
|
| 632 |
+
|
| 633 |
+
/**
|
| 634 |
+
* @desc Delete all featured banners
|
| 635 |
+
* @route DELETE /api/content/featured-banners
|
| 636 |
+
* @access Private/Admin
|
| 637 |
+
*/
|
| 638 |
+
exports.deleteAllFeaturedBanners = async (req, res) => {
|
| 639 |
+
try {
|
| 640 |
+
await FeaturedBanner.deleteMany({});
|
| 641 |
+
|
| 642 |
+
res.status(200).json({
|
| 643 |
+
status: 'success',
|
| 644 |
+
message: 'All banners deleted successfully',
|
| 645 |
+
});
|
| 646 |
+
} catch (error) {
|
| 647 |
+
res.status(500).json({
|
| 648 |
+
status: 'fail',
|
| 649 |
+
message: error.message,
|
| 650 |
+
});
|
| 651 |
+
}
|
| 652 |
+
};
|
| 653 |
+
|
| 654 |
+
// ============================================
|
| 655 |
+
// WHY CHOOSE US SECTION
|
| 656 |
+
// ============================================
|
| 657 |
+
|
| 658 |
+
/**
|
| 659 |
+
* @desc Get Why Choose Us settings
|
| 660 |
+
* @route GET /api/content/why-choose-us
|
| 661 |
+
* @access Public
|
| 662 |
+
*/
|
| 663 |
+
exports.getWhyChooseUs = async (req, res) => {
|
| 664 |
+
try {
|
| 665 |
+
const settings = await WhyChooseUs.getSettings();
|
| 666 |
+
res.status(200).json({
|
| 667 |
+
status: 'success',
|
| 668 |
+
data: settings,
|
| 669 |
+
});
|
| 670 |
+
} catch (error) {
|
| 671 |
+
res.status(500).json({
|
| 672 |
+
status: 'fail',
|
| 673 |
+
message: error.message,
|
| 674 |
+
});
|
| 675 |
+
}
|
| 676 |
+
};
|
| 677 |
+
|
| 678 |
+
/**
|
| 679 |
+
* @desc Update Why Choose Us settings
|
| 680 |
+
* @route PUT /api/content/why-choose-us
|
| 681 |
+
* @access Private/Admin
|
| 682 |
+
*/
|
| 683 |
+
exports.updateWhyChooseUs = async (req, res) => {
|
| 684 |
+
try {
|
| 685 |
+
const settings = await WhyChooseUs.getSettings();
|
| 686 |
+
|
| 687 |
+
const restrictedFields = ['_id', '__v', 'createdAt', 'updatedAt'];
|
| 688 |
+
Object.keys(req.body).forEach((key) => {
|
| 689 |
+
if (!restrictedFields.includes(key)) {
|
| 690 |
+
settings[key] = req.body[key];
|
| 691 |
+
}
|
| 692 |
+
});
|
| 693 |
+
|
| 694 |
+
const updated = await settings.save();
|
| 695 |
+
res.status(200).json({
|
| 696 |
+
status: 'success',
|
| 697 |
+
data: updated,
|
| 698 |
+
});
|
| 699 |
+
} catch (error) {
|
| 700 |
+
res.status(400).json({
|
| 701 |
+
status: 'fail',
|
| 702 |
+
message: error.message,
|
| 703 |
+
});
|
| 704 |
+
}
|
| 705 |
+
};
|
controllers/favoriteController.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const Favorite = require('../models/favoriteModel');
|
| 2 |
+
const Product = require('../models/productModel');
|
| 3 |
+
|
| 4 |
+
exports.addToFavorites = async (req, res) => {
|
| 5 |
+
try {
|
| 6 |
+
const { productId } = req.body;
|
| 7 |
+
|
| 8 |
+
// Check product exists
|
| 9 |
+
const productExists = await Product.findById(productId);
|
| 10 |
+
if (!productExists) {
|
| 11 |
+
return res.status(404).json({ message: 'Product not found' });
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
// Check if user has favorite list
|
| 15 |
+
let fav = await Favorite.findOne({ user: req.user._id });
|
| 16 |
+
|
| 17 |
+
if (!fav) {
|
| 18 |
+
fav = await Favorite.create({
|
| 19 |
+
user: req.user._id,
|
| 20 |
+
products: [productId],
|
| 21 |
+
});
|
| 22 |
+
} else {
|
| 23 |
+
if (fav.products.some((p) => p.toString() === productId.toString())) {
|
| 24 |
+
return res
|
| 25 |
+
.status(400)
|
| 26 |
+
.json({ message: 'Product already in favorites' });
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
fav.products.push(productId);
|
| 30 |
+
await fav.save();
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
res.status(200).json({
|
| 34 |
+
status: 'success',
|
| 35 |
+
favorites: fav,
|
| 36 |
+
});
|
| 37 |
+
} catch (err) {
|
| 38 |
+
res.status(500).json({ message: 'Failed to add', error: err.message });
|
| 39 |
+
}
|
| 40 |
+
};
|
| 41 |
+
|
| 42 |
+
exports.removeFromFavorites = async (req, res) => {
|
| 43 |
+
try {
|
| 44 |
+
const { productId } = req.body;
|
| 45 |
+
|
| 46 |
+
const fav = await Favorite.findOne({ user: req.user._id });
|
| 47 |
+
|
| 48 |
+
if (!fav) {
|
| 49 |
+
return res.status(404).json({ message: 'Favorites list not found' });
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
fav.products = fav.products.filter(
|
| 53 |
+
(p) => p.toString() !== productId.toString(),
|
| 54 |
+
);
|
| 55 |
+
|
| 56 |
+
await fav.save();
|
| 57 |
+
|
| 58 |
+
res.status(200).json({
|
| 59 |
+
status: 'success',
|
| 60 |
+
message: 'Product removed',
|
| 61 |
+
favorites: fav,
|
| 62 |
+
});
|
| 63 |
+
} catch (err) {
|
| 64 |
+
res.status(500).json({ message: 'Failed to remove', error: err.message });
|
| 65 |
+
}
|
| 66 |
+
};
|
| 67 |
+
|
| 68 |
+
exports.getFavorites = async (req, res) => {
|
| 69 |
+
try {
|
| 70 |
+
const fav = await Favorite.findOne({ user: req.user._id }).populate(
|
| 71 |
+
'products',
|
| 72 |
+
);
|
| 73 |
+
|
| 74 |
+
res.status(200).json({
|
| 75 |
+
status: 'success',
|
| 76 |
+
favorites: fav || { user: req.user._id, products: [] },
|
| 77 |
+
});
|
| 78 |
+
} catch (err) {
|
| 79 |
+
res
|
| 80 |
+
.status(500)
|
| 81 |
+
.json({ message: 'Failed to load favorites', error: err.message });
|
| 82 |
+
}
|
| 83 |
+
};
|
| 84 |
+
|
| 85 |
+
exports.clearFavorites = async (req, res) => {
|
| 86 |
+
try {
|
| 87 |
+
const fav = await Favorite.findOne({ user: req.user._id });
|
| 88 |
+
|
| 89 |
+
if (!fav) {
|
| 90 |
+
return res.status(404).json({ message: 'Favorites list not found' });
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
fav.products = [];
|
| 94 |
+
await fav.save();
|
| 95 |
+
|
| 96 |
+
res.status(200).json({
|
| 97 |
+
status: 'success',
|
| 98 |
+
message: 'All products removed from favorites',
|
| 99 |
+
favorites: fav,
|
| 100 |
+
});
|
| 101 |
+
} catch (err) {
|
| 102 |
+
res
|
| 103 |
+
.status(500)
|
| 104 |
+
.json({ message: 'Failed to clear favorites', error: err.message });
|
| 105 |
+
}
|
| 106 |
+
};
|
controllers/financeController.js
ADDED
|
@@ -0,0 +1,1711 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const Order = require('../models/orderModel');
|
| 2 |
+
const Transaction = require('../models/transactionModel');
|
| 3 |
+
const Provider = require('../models/providerModel');
|
| 4 |
+
const Product = require('../models/productModel');
|
| 5 |
+
const Expense = require('../models/expenseModel');
|
| 6 |
+
const Capital = require('../models/capitalModel');
|
| 7 |
+
const {
|
| 8 |
+
CapitalAccount,
|
| 9 |
+
CapitalTransfer,
|
| 10 |
+
} = require('../models/capitalAccountModel');
|
| 11 |
+
const User = require('../models/userModel');
|
| 12 |
+
|
| 13 |
+
// ─────────────────────────────────────────────
|
| 14 |
+
// HELPER: Get only external (non-platform) providers
|
| 15 |
+
// Excludes the platform owner (Yadawy) by checking:
|
| 16 |
+
// 1. Provider's linked user has role admin/superadmin
|
| 17 |
+
// 2. Provider has no linked user but storeName matches platform name
|
| 18 |
+
// ─────────────────────────────────────────────
|
| 19 |
+
const PLATFORM_STORE_NAMES = ['samoulla', 'سامويلا'];
|
| 20 |
+
|
| 21 |
+
const getExternalProviders = async () => {
|
| 22 |
+
// Find user IDs that are admin or superadmin
|
| 23 |
+
const adminUsers = await User.find(
|
| 24 |
+
{ role: { $in: ['admin', 'superadmin'] } },
|
| 25 |
+
'_id',
|
| 26 |
+
);
|
| 27 |
+
const adminUserIds = adminUsers.map((u) => u._id.toString());
|
| 28 |
+
|
| 29 |
+
// Fetch all providers with their user populated
|
| 30 |
+
const allProviders = await Provider.find({}).populate('user', 'role');
|
| 31 |
+
|
| 32 |
+
// Filter out platform-owned providers
|
| 33 |
+
return allProviders.filter((provider) => {
|
| 34 |
+
// Exclude if linked user is admin/superadmin
|
| 35 |
+
if (provider.user && adminUserIds.includes(provider.user._id.toString())) {
|
| 36 |
+
return false;
|
| 37 |
+
}
|
| 38 |
+
// Exclude if storeName matches platform names (case-insensitive)
|
| 39 |
+
if (
|
| 40 |
+
PLATFORM_STORE_NAMES.includes(
|
| 41 |
+
(provider.storeName || '').toLowerCase().trim(),
|
| 42 |
+
)
|
| 43 |
+
) {
|
| 44 |
+
return false;
|
| 45 |
+
}
|
| 46 |
+
return true;
|
| 47 |
+
});
|
| 48 |
+
};
|
| 49 |
+
|
| 50 |
+
// ─────────────────────────────────────────────
|
| 51 |
+
// HELPER: Calculate financials with optional date range
|
| 52 |
+
// ─────────────────────────────────────────────
|
| 53 |
+
const calculateFinancials = async (startDate, endDate) => {
|
| 54 |
+
const query = { orderStatus: 'completed' };
|
| 55 |
+
|
| 56 |
+
if (startDate || endDate) {
|
| 57 |
+
query.createdAt = {};
|
| 58 |
+
if (startDate) query.createdAt.$gte = new Date(startDate);
|
| 59 |
+
if (endDate) query.createdAt.$lte = new Date(endDate);
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
const orders = await Order.find(query).populate({
|
| 63 |
+
path: 'items.product',
|
| 64 |
+
populate: {
|
| 65 |
+
path: 'provider',
|
| 66 |
+
populate: { path: 'user' },
|
| 67 |
+
},
|
| 68 |
+
});
|
| 69 |
+
|
| 70 |
+
let totalWebsiteProfit = 0;
|
| 71 |
+
let websiteOwnedProfit = 0;
|
| 72 |
+
let vendorOwnedProfit = 0;
|
| 73 |
+
let totalRevenue = 0;
|
| 74 |
+
|
| 75 |
+
const vendorMap = {};
|
| 76 |
+
|
| 77 |
+
for (const order of orders) {
|
| 78 |
+
for (const item of order.items) {
|
| 79 |
+
const product = item.product;
|
| 80 |
+
if (!product || !product.provider) continue;
|
| 81 |
+
|
| 82 |
+
const quantity = item.quantity;
|
| 83 |
+
const sellingPrice = item.unitPrice;
|
| 84 |
+
const costPrice = product.costPrice || 0;
|
| 85 |
+
const purchasePrice = product.purchasePrice || 0;
|
| 86 |
+
|
| 87 |
+
const itemRevenue = sellingPrice * quantity;
|
| 88 |
+
const itemWebsiteProfit = (sellingPrice - costPrice) * quantity;
|
| 89 |
+
|
| 90 |
+
totalRevenue += itemRevenue;
|
| 91 |
+
|
| 92 |
+
const providerUser = product.provider.user;
|
| 93 |
+
const providerStoreName = (product.provider.storeName || '')
|
| 94 |
+
.toLowerCase()
|
| 95 |
+
.trim();
|
| 96 |
+
let isWebsiteOwned = false;
|
| 97 |
+
|
| 98 |
+
if (
|
| 99 |
+
(providerUser &&
|
| 100 |
+
(providerUser.role === 'admin' ||
|
| 101 |
+
providerUser.role === 'superadmin')) ||
|
| 102 |
+
// If provider is Samoulla or provider ID matches platform IDs
|
| 103 |
+
PLATFORM_STORE_NAMES.includes(providerStoreName)
|
| 104 |
+
) {
|
| 105 |
+
isWebsiteOwned = true;
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
totalWebsiteProfit += itemWebsiteProfit;
|
| 109 |
+
|
| 110 |
+
if (isWebsiteOwned) {
|
| 111 |
+
websiteOwnedProfit += itemWebsiteProfit;
|
| 112 |
+
} else {
|
| 113 |
+
vendorOwnedProfit += itemWebsiteProfit;
|
| 114 |
+
|
| 115 |
+
const pid = product.provider._id.toString();
|
| 116 |
+
|
| 117 |
+
if (!vendorMap[pid]) {
|
| 118 |
+
vendorMap[pid] = {
|
| 119 |
+
totalDeliveredSales: 0,
|
| 120 |
+
totalRevenueOwed: 0,
|
| 121 |
+
totalPurchaseCost: 0,
|
| 122 |
+
};
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
vendorMap[pid].totalDeliveredSales += sellingPrice * quantity;
|
| 126 |
+
vendorMap[pid].totalRevenueOwed += costPrice * quantity;
|
| 127 |
+
vendorMap[pid].totalPurchaseCost += purchasePrice * quantity;
|
| 128 |
+
}
|
| 129 |
+
}
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
return {
|
| 133 |
+
totalWebsiteProfit,
|
| 134 |
+
websiteOwnedProfit,
|
| 135 |
+
vendorOwnedProfit,
|
| 136 |
+
totalRevenue,
|
| 137 |
+
vendorMap,
|
| 138 |
+
};
|
| 139 |
+
};
|
| 140 |
+
|
| 141 |
+
// ═════════════════════════════════════════════
|
| 142 |
+
// ADMIN ENDPOINTS
|
| 143 |
+
// ═════════════════════════════════════════════
|
| 144 |
+
|
| 145 |
+
// ───────────────��─────────────────────────────
|
| 146 |
+
// GET /finance/admin/stats
|
| 147 |
+
// Admin Financial Overview + Vendors Table
|
| 148 |
+
// Supports ?startDate=...&endDate=...
|
| 149 |
+
// ─────────────────────────────────────────────
|
| 150 |
+
exports.getAdminFinancialStats = async (req, res) => {
|
| 151 |
+
try {
|
| 152 |
+
const { startDate, endDate } = req.query;
|
| 153 |
+
|
| 154 |
+
const {
|
| 155 |
+
totalWebsiteProfit,
|
| 156 |
+
websiteOwnedProfit,
|
| 157 |
+
vendorOwnedProfit,
|
| 158 |
+
totalRevenue,
|
| 159 |
+
vendorMap,
|
| 160 |
+
} = await calculateFinancials(startDate, endDate);
|
| 161 |
+
|
| 162 |
+
const allProviders = await getExternalProviders();
|
| 163 |
+
|
| 164 |
+
// Get payouts (optionally filtered by date)
|
| 165 |
+
const payoutQuery = { type: 'payout' };
|
| 166 |
+
if (startDate || endDate) {
|
| 167 |
+
payoutQuery.date = {};
|
| 168 |
+
if (startDate) payoutQuery.date.$gte = new Date(startDate);
|
| 169 |
+
if (endDate) payoutQuery.date.$lte = new Date(endDate);
|
| 170 |
+
}
|
| 171 |
+
const payouts = await Transaction.find(payoutQuery);
|
| 172 |
+
const payoutMap = {};
|
| 173 |
+
payouts.forEach((t) => {
|
| 174 |
+
const pid = t.provider.toString();
|
| 175 |
+
payoutMap[pid] = (payoutMap[pid] || 0) + t.amount;
|
| 176 |
+
});
|
| 177 |
+
|
| 178 |
+
const vendorSummary = [];
|
| 179 |
+
|
| 180 |
+
for (const provider of allProviders) {
|
| 181 |
+
const pid = provider._id.toString();
|
| 182 |
+
const stats = vendorMap[pid] || {
|
| 183 |
+
totalDeliveredSales: 0,
|
| 184 |
+
totalRevenueOwed: 0,
|
| 185 |
+
totalPurchaseCost: 0,
|
| 186 |
+
};
|
| 187 |
+
const paidAmount = payoutMap[pid] || 0;
|
| 188 |
+
|
| 189 |
+
const vendorProfit = stats.totalRevenueOwed - stats.totalPurchaseCost;
|
| 190 |
+
const platformCommission =
|
| 191 |
+
stats.totalDeliveredSales - stats.totalRevenueOwed;
|
| 192 |
+
|
| 193 |
+
vendorSummary.push({
|
| 194 |
+
provider: {
|
| 195 |
+
_id: provider._id,
|
| 196 |
+
name: provider.name,
|
| 197 |
+
storeName: provider.storeName,
|
| 198 |
+
},
|
| 199 |
+
totalSales: stats.totalDeliveredSales,
|
| 200 |
+
vendorProfit,
|
| 201 |
+
platformCommission,
|
| 202 |
+
totalOwed: stats.totalRevenueOwed,
|
| 203 |
+
paidAmount,
|
| 204 |
+
remainingBalance: stats.totalRevenueOwed - paidAmount,
|
| 205 |
+
});
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
// Get expenses total for the period
|
| 209 |
+
const expenseQuery = {};
|
| 210 |
+
if (startDate || endDate) {
|
| 211 |
+
expenseQuery.date = {};
|
| 212 |
+
if (startDate) expenseQuery.date.$gte = new Date(startDate);
|
| 213 |
+
if (endDate) expenseQuery.date.$lte = new Date(endDate);
|
| 214 |
+
}
|
| 215 |
+
const expenses = await Expense.find(expenseQuery);
|
| 216 |
+
const totalExpenses = expenses.reduce((sum, e) => sum + e.amount, 0);
|
| 217 |
+
|
| 218 |
+
// Get capital info
|
| 219 |
+
const capital = await Capital.getCapital();
|
| 220 |
+
|
| 221 |
+
res.status(200).json({
|
| 222 |
+
status: 'success',
|
| 223 |
+
data: {
|
| 224 |
+
totalRevenue,
|
| 225 |
+
totalWebsiteProfit,
|
| 226 |
+
profitSplit: {
|
| 227 |
+
websiteOwned: websiteOwnedProfit,
|
| 228 |
+
vendorOwned: vendorOwnedProfit,
|
| 229 |
+
},
|
| 230 |
+
totalExpenses,
|
| 231 |
+
netProfit: totalWebsiteProfit - totalExpenses,
|
| 232 |
+
capital: {
|
| 233 |
+
initial: capital.initialCapital,
|
| 234 |
+
current: capital.currentCapital,
|
| 235 |
+
},
|
| 236 |
+
vendorSummary,
|
| 237 |
+
},
|
| 238 |
+
});
|
| 239 |
+
} catch (err) {
|
| 240 |
+
res.status(500).json({ status: 'error', message: err.message });
|
| 241 |
+
}
|
| 242 |
+
};
|
| 243 |
+
|
| 244 |
+
// ─────────────────────────────────────────────
|
| 245 |
+
// POST /finance/admin/payout
|
| 246 |
+
// Create a payout and log to capital
|
| 247 |
+
// ─────────────────────────────────────────────
|
| 248 |
+
exports.createPayout = async (req, res) => {
|
| 249 |
+
try {
|
| 250 |
+
const { providerId, amount, note, paymentMethod, referenceId } = req.body;
|
| 251 |
+
|
| 252 |
+
if (!amount || amount <= 0) {
|
| 253 |
+
return res
|
| 254 |
+
.status(400)
|
| 255 |
+
.json({ status: 'fail', message: 'Amount must be positive' });
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
const provider = await Provider.findById(providerId);
|
| 259 |
+
if (!provider) {
|
| 260 |
+
return res
|
| 261 |
+
.status(404)
|
| 262 |
+
.json({ status: 'fail', message: 'Provider not found' });
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
const transaction = await Transaction.create({
|
| 266 |
+
provider: providerId,
|
| 267 |
+
admin: req.user._id,
|
| 268 |
+
amount,
|
| 269 |
+
type: 'payout',
|
| 270 |
+
note,
|
| 271 |
+
paymentMethod,
|
| 272 |
+
referenceId,
|
| 273 |
+
});
|
| 274 |
+
|
| 275 |
+
// Decrease capital
|
| 276 |
+
const capital = await Capital.getCapital();
|
| 277 |
+
capital.currentCapital -= amount;
|
| 278 |
+
capital.logs.push({
|
| 279 |
+
type: 'vendor_payout',
|
| 280 |
+
amount: -amount,
|
| 281 |
+
balanceAfter: capital.currentCapital,
|
| 282 |
+
reference: transaction._id,
|
| 283 |
+
referenceModel: 'Transaction',
|
| 284 |
+
description:
|
| 285 |
+
`Payout to ${provider.storeName || provider.name}: ${note || ''}`.trim(),
|
| 286 |
+
date: new Date(),
|
| 287 |
+
createdBy: req.user._id,
|
| 288 |
+
});
|
| 289 |
+
await capital.save();
|
| 290 |
+
|
| 291 |
+
res.status(201).json({
|
| 292 |
+
status: 'success',
|
| 293 |
+
data: {
|
| 294 |
+
transaction,
|
| 295 |
+
capitalAfter: capital.currentCapital,
|
| 296 |
+
},
|
| 297 |
+
});
|
| 298 |
+
} catch (err) {
|
| 299 |
+
res.status(500).json({ status: 'error', message: err.message });
|
| 300 |
+
}
|
| 301 |
+
};
|
| 302 |
+
|
| 303 |
+
// ═════════════════════════════════════════════
|
| 304 |
+
// CAPITAL MANAGEMENT
|
| 305 |
+
// ═════════════════════════════════════════════
|
| 306 |
+
|
| 307 |
+
// GET /finance/admin/capital
|
| 308 |
+
exports.getCapital = async (req, res) => {
|
| 309 |
+
try {
|
| 310 |
+
const capital = await Capital.getCapital();
|
| 311 |
+
res.status(200).json({
|
| 312 |
+
status: 'success',
|
| 313 |
+
data: {
|
| 314 |
+
initialCapital: capital.initialCapital,
|
| 315 |
+
currentCapital: capital.currentCapital,
|
| 316 |
+
logsCount: capital.logs.length,
|
| 317 |
+
},
|
| 318 |
+
});
|
| 319 |
+
} catch (err) {
|
| 320 |
+
res.status(500).json({ status: 'error', message: err.message });
|
| 321 |
+
}
|
| 322 |
+
};
|
| 323 |
+
|
| 324 |
+
// PUT /finance/admin/capital
|
| 325 |
+
// Set/update initial capital
|
| 326 |
+
exports.setCapital = async (req, res) => {
|
| 327 |
+
try {
|
| 328 |
+
const { initialCapital } = req.body;
|
| 329 |
+
|
| 330 |
+
if (initialCapital === undefined || initialCapital < 0) {
|
| 331 |
+
return res.status(400).json({
|
| 332 |
+
status: 'fail',
|
| 333 |
+
message: 'initialCapital must be a non-negative number',
|
| 334 |
+
});
|
| 335 |
+
}
|
| 336 |
+
|
| 337 |
+
const capital = await Capital.getCapital();
|
| 338 |
+
const diff = initialCapital - capital.initialCapital;
|
| 339 |
+
|
| 340 |
+
capital.initialCapital = initialCapital;
|
| 341 |
+
capital.currentCapital += diff;
|
| 342 |
+
|
| 343 |
+
capital.logs.push({
|
| 344 |
+
type: 'initial',
|
| 345 |
+
amount: diff,
|
| 346 |
+
balanceAfter: capital.currentCapital,
|
| 347 |
+
description: `Initial capital set to ${initialCapital}`,
|
| 348 |
+
date: new Date(),
|
| 349 |
+
createdBy: req.user._id,
|
| 350 |
+
});
|
| 351 |
+
|
| 352 |
+
await capital.save();
|
| 353 |
+
|
| 354 |
+
res.status(200).json({
|
| 355 |
+
status: 'success',
|
| 356 |
+
data: {
|
| 357 |
+
initialCapital: capital.initialCapital,
|
| 358 |
+
currentCapital: capital.currentCapital,
|
| 359 |
+
},
|
| 360 |
+
});
|
| 361 |
+
} catch (err) {
|
| 362 |
+
res.status(500).json({ status: 'error', message: err.message });
|
| 363 |
+
}
|
| 364 |
+
};
|
| 365 |
+
|
| 366 |
+
// POST /finance/admin/capital/adjust
|
| 367 |
+
// Manual adjustment (add/subtract) to capital
|
| 368 |
+
exports.adjustCapital = async (req, res) => {
|
| 369 |
+
try {
|
| 370 |
+
const { amount, description } = req.body;
|
| 371 |
+
|
| 372 |
+
if (amount === undefined || amount === 0) {
|
| 373 |
+
return res
|
| 374 |
+
.status(400)
|
| 375 |
+
.json({ status: 'fail', message: 'Amount must be a non-zero number' });
|
| 376 |
+
}
|
| 377 |
+
|
| 378 |
+
const capital = await Capital.getCapital();
|
| 379 |
+
capital.currentCapital += amount;
|
| 380 |
+
|
| 381 |
+
capital.logs.push({
|
| 382 |
+
type: 'adjustment',
|
| 383 |
+
amount,
|
| 384 |
+
balanceAfter: capital.currentCapital,
|
| 385 |
+
description:
|
| 386 |
+
description || `Manual adjustment: ${amount > 0 ? '+' : ''}${amount}`,
|
| 387 |
+
date: new Date(),
|
| 388 |
+
createdBy: req.user._id,
|
| 389 |
+
});
|
| 390 |
+
|
| 391 |
+
await capital.save();
|
| 392 |
+
|
| 393 |
+
res.status(200).json({
|
| 394 |
+
status: 'success',
|
| 395 |
+
data: {
|
| 396 |
+
currentCapital: capital.currentCapital,
|
| 397 |
+
},
|
| 398 |
+
});
|
| 399 |
+
} catch (err) {
|
| 400 |
+
res.status(500).json({ status: 'error', message: err.message });
|
| 401 |
+
}
|
| 402 |
+
};
|
| 403 |
+
|
| 404 |
+
// GET /finance/admin/capital/timeline
|
| 405 |
+
// Returns capital log entries for chart display
|
| 406 |
+
exports.getCapitalTimeline = async (req, res) => {
|
| 407 |
+
try {
|
| 408 |
+
const { startDate, endDate, limit } = req.query;
|
| 409 |
+
const capital = await Capital.getCapital();
|
| 410 |
+
|
| 411 |
+
let logs = capital.logs || [];
|
| 412 |
+
|
| 413 |
+
// Filter by date
|
| 414 |
+
if (startDate) {
|
| 415 |
+
const sd = new Date(startDate);
|
| 416 |
+
logs = logs.filter((l) => l.date >= sd);
|
| 417 |
+
}
|
| 418 |
+
if (endDate) {
|
| 419 |
+
const ed = new Date(endDate);
|
| 420 |
+
logs = logs.filter((l) => l.date <= ed);
|
| 421 |
+
}
|
| 422 |
+
|
| 423 |
+
// Sort by date ascending for timeline
|
| 424 |
+
logs.sort((a, b) => a.date - b.date);
|
| 425 |
+
|
| 426 |
+
// Limit
|
| 427 |
+
if (limit) {
|
| 428 |
+
logs = logs.slice(-parseInt(limit, 10));
|
| 429 |
+
}
|
| 430 |
+
|
| 431 |
+
res.status(200).json({
|
| 432 |
+
status: 'success',
|
| 433 |
+
results: logs.length,
|
| 434 |
+
data: { logs },
|
| 435 |
+
});
|
| 436 |
+
} catch (err) {
|
| 437 |
+
res.status(500).json({ status: 'error', message: err.message });
|
| 438 |
+
}
|
| 439 |
+
};
|
| 440 |
+
|
| 441 |
+
// ═════════════════════════════════════════════
|
| 442 |
+
// EXPENSE MANAGEMENT
|
| 443 |
+
// ═════════════════════════════════════════════
|
| 444 |
+
|
| 445 |
+
// GET /finance/admin/expenses
|
| 446 |
+
exports.getExpenses = async (req, res) => {
|
| 447 |
+
try {
|
| 448 |
+
const { startDate, endDate, category, page = 1, limit = 50 } = req.query;
|
| 449 |
+
const query = {};
|
| 450 |
+
|
| 451 |
+
if (startDate || endDate) {
|
| 452 |
+
query.date = {};
|
| 453 |
+
if (startDate) query.date.$gte = new Date(startDate);
|
| 454 |
+
if (endDate) query.date.$lte = new Date(endDate);
|
| 455 |
+
}
|
| 456 |
+
if (category) query.category = category;
|
| 457 |
+
|
| 458 |
+
const skip = (parseInt(page, 10) - 1) * parseInt(limit, 10);
|
| 459 |
+
|
| 460 |
+
const [expenses, total] = await Promise.all([
|
| 461 |
+
Expense.find(query)
|
| 462 |
+
.sort('-date')
|
| 463 |
+
.skip(skip)
|
| 464 |
+
.limit(parseInt(limit, 10))
|
| 465 |
+
.populate('createdBy', 'name email'),
|
| 466 |
+
Expense.countDocuments(query),
|
| 467 |
+
]);
|
| 468 |
+
|
| 469 |
+
const totalAmount = await Expense.aggregate([
|
| 470 |
+
{ $match: query },
|
| 471 |
+
{ $group: { _id: null, total: { $sum: '$amount' } } },
|
| 472 |
+
]);
|
| 473 |
+
|
| 474 |
+
// Category breakdown
|
| 475 |
+
const categoryBreakdown = await Expense.aggregate([
|
| 476 |
+
{ $match: query },
|
| 477 |
+
{
|
| 478 |
+
$group: {
|
| 479 |
+
_id: '$category',
|
| 480 |
+
total: { $sum: '$amount' },
|
| 481 |
+
count: { $sum: 1 },
|
| 482 |
+
},
|
| 483 |
+
},
|
| 484 |
+
{ $sort: { total: -1 } },
|
| 485 |
+
]);
|
| 486 |
+
|
| 487 |
+
res.status(200).json({
|
| 488 |
+
status: 'success',
|
| 489 |
+
results: expenses.length,
|
| 490 |
+
totalRecords: total,
|
| 491 |
+
data: {
|
| 492 |
+
expenses,
|
| 493 |
+
totalExpenses: (totalAmount[0] && totalAmount[0].total) || 0,
|
| 494 |
+
categoryBreakdown,
|
| 495 |
+
},
|
| 496 |
+
});
|
| 497 |
+
} catch (err) {
|
| 498 |
+
res.status(500).json({ status: 'error', message: err.message });
|
| 499 |
+
}
|
| 500 |
+
};
|
| 501 |
+
|
| 502 |
+
// POST /finance/admin/expenses
|
| 503 |
+
exports.createExpense = async (req, res) => {
|
| 504 |
+
try {
|
| 505 |
+
const { category, amount, date, notes } = req.body;
|
| 506 |
+
|
| 507 |
+
if (!category || !amount) {
|
| 508 |
+
return res
|
| 509 |
+
.status(400)
|
| 510 |
+
.json({ status: 'fail', message: 'category and amount are required' });
|
| 511 |
+
}
|
| 512 |
+
|
| 513 |
+
const expense = await Expense.create({
|
| 514 |
+
category,
|
| 515 |
+
amount,
|
| 516 |
+
date: date || new Date(),
|
| 517 |
+
notes: notes || '',
|
| 518 |
+
createdBy: req.user._id,
|
| 519 |
+
});
|
| 520 |
+
|
| 521 |
+
// Decrease capital
|
| 522 |
+
const capital = await Capital.getCapital();
|
| 523 |
+
capital.currentCapital -= amount;
|
| 524 |
+
capital.logs.push({
|
| 525 |
+
type: 'expense',
|
| 526 |
+
amount: -amount,
|
| 527 |
+
balanceAfter: capital.currentCapital,
|
| 528 |
+
reference: expense._id,
|
| 529 |
+
referenceModel: 'Expense',
|
| 530 |
+
description: `Expense (${category}): ${notes || ''}`.trim(),
|
| 531 |
+
date: expense.date,
|
| 532 |
+
createdBy: req.user._id,
|
| 533 |
+
});
|
| 534 |
+
await capital.save();
|
| 535 |
+
|
| 536 |
+
res.status(201).json({
|
| 537 |
+
status: 'success',
|
| 538 |
+
data: {
|
| 539 |
+
expense,
|
| 540 |
+
capitalAfter: capital.currentCapital,
|
| 541 |
+
},
|
| 542 |
+
});
|
| 543 |
+
} catch (err) {
|
| 544 |
+
res.status(500).json({ status: 'error', message: err.message });
|
| 545 |
+
}
|
| 546 |
+
};
|
| 547 |
+
|
| 548 |
+
// PUT /finance/admin/expenses/:id
|
| 549 |
+
exports.updateExpense = async (req, res) => {
|
| 550 |
+
try {
|
| 551 |
+
const expense = await Expense.findById(req.params.id);
|
| 552 |
+
if (!expense) {
|
| 553 |
+
return res
|
| 554 |
+
.status(404)
|
| 555 |
+
.json({ status: 'fail', message: 'Expense not found' });
|
| 556 |
+
}
|
| 557 |
+
|
| 558 |
+
const oldAmount = expense.amount;
|
| 559 |
+
|
| 560 |
+
// Update fields
|
| 561 |
+
if (req.body.category) expense.category = req.body.category;
|
| 562 |
+
if (req.body.amount !== undefined) expense.amount = req.body.amount;
|
| 563 |
+
if (req.body.date) expense.date = req.body.date;
|
| 564 |
+
if (req.body.notes !== undefined) expense.notes = req.body.notes;
|
| 565 |
+
|
| 566 |
+
await expense.save();
|
| 567 |
+
|
| 568 |
+
// Adjust capital for difference
|
| 569 |
+
const diff = oldAmount - expense.amount; // positive if expense decreased
|
| 570 |
+
if (diff !== 0) {
|
| 571 |
+
const capital = await Capital.getCapital();
|
| 572 |
+
capital.currentCapital += diff;
|
| 573 |
+
capital.logs.push({
|
| 574 |
+
type: 'adjustment',
|
| 575 |
+
amount: diff,
|
| 576 |
+
balanceAfter: capital.currentCapital,
|
| 577 |
+
reference: expense._id,
|
| 578 |
+
referenceModel: 'Expense',
|
| 579 |
+
description: `Expense updated (${expense.category}): ${oldAmount} → ${expense.amount}`,
|
| 580 |
+
date: new Date(),
|
| 581 |
+
createdBy: req.user._id,
|
| 582 |
+
});
|
| 583 |
+
await capital.save();
|
| 584 |
+
}
|
| 585 |
+
|
| 586 |
+
res.status(200).json({
|
| 587 |
+
status: 'success',
|
| 588 |
+
data: { expense },
|
| 589 |
+
});
|
| 590 |
+
} catch (err) {
|
| 591 |
+
res.status(500).json({ status: 'error', message: err.message });
|
| 592 |
+
}
|
| 593 |
+
};
|
| 594 |
+
|
| 595 |
+
// DELETE /finance/admin/expenses/:id
|
| 596 |
+
exports.deleteExpense = async (req, res) => {
|
| 597 |
+
try {
|
| 598 |
+
const expense = await Expense.findById(req.params.id);
|
| 599 |
+
if (!expense) {
|
| 600 |
+
return res
|
| 601 |
+
.status(404)
|
| 602 |
+
.json({ status: 'fail', message: 'Expense not found' });
|
| 603 |
+
}
|
| 604 |
+
|
| 605 |
+
// Restore capital
|
| 606 |
+
const capital = await Capital.getCapital();
|
| 607 |
+
capital.currentCapital += expense.amount;
|
| 608 |
+
capital.logs.push({
|
| 609 |
+
type: 'adjustment',
|
| 610 |
+
amount: expense.amount,
|
| 611 |
+
balanceAfter: capital.currentCapital,
|
| 612 |
+
description: `Expense deleted (${expense.category}): +${expense.amount} restored`,
|
| 613 |
+
date: new Date(),
|
| 614 |
+
createdBy: req.user._id,
|
| 615 |
+
});
|
| 616 |
+
await capital.save();
|
| 617 |
+
|
| 618 |
+
await Expense.findByIdAndDelete(req.params.id);
|
| 619 |
+
|
| 620 |
+
res.status(200).json({
|
| 621 |
+
status: 'success',
|
| 622 |
+
message: 'Expense deleted and capital restored',
|
| 623 |
+
});
|
| 624 |
+
} catch (err) {
|
| 625 |
+
res.status(500).json({ status: 'error', message: err.message });
|
| 626 |
+
}
|
| 627 |
+
};
|
| 628 |
+
|
| 629 |
+
// ═════════════════════════════════════════════
|
| 630 |
+
// ANALYTICS (Admin)
|
| 631 |
+
// ═════════════════════════════════════════════
|
| 632 |
+
|
| 633 |
+
// GET /finance/admin/analytics/monthly-profit
|
| 634 |
+
// Returns monthly profit data for charts
|
| 635 |
+
exports.getMonthlyProfit = async (req, res) => {
|
| 636 |
+
try {
|
| 637 |
+
const { year } = req.query;
|
| 638 |
+
const targetYear = parseInt(year, 10) || new Date().getFullYear();
|
| 639 |
+
|
| 640 |
+
const startOfYear = new Date(targetYear, 0, 1);
|
| 641 |
+
const endOfYear = new Date(targetYear, 11, 31, 23, 59, 59);
|
| 642 |
+
|
| 643 |
+
// Aggregate completed orders by month
|
| 644 |
+
const monthlyData = await Order.aggregate([
|
| 645 |
+
{
|
| 646 |
+
$match: {
|
| 647 |
+
orderStatus: 'completed',
|
| 648 |
+
createdAt: { $gte: startOfYear, $lte: endOfYear },
|
| 649 |
+
},
|
| 650 |
+
},
|
| 651 |
+
{ $unwind: '$items' },
|
| 652 |
+
{
|
| 653 |
+
$lookup: {
|
| 654 |
+
from: 'products',
|
| 655 |
+
localField: 'items.product',
|
| 656 |
+
foreignField: '_id',
|
| 657 |
+
as: 'productInfo',
|
| 658 |
+
},
|
| 659 |
+
},
|
| 660 |
+
{ $unwind: { path: '$productInfo', preserveNullAndEmptyArrays: true } },
|
| 661 |
+
{
|
| 662 |
+
$group: {
|
| 663 |
+
_id: { $month: '$createdAt' },
|
| 664 |
+
totalRevenue: {
|
| 665 |
+
$sum: { $multiply: ['$items.unitPrice', '$items.quantity'] },
|
| 666 |
+
},
|
| 667 |
+
totalCost: {
|
| 668 |
+
$sum: {
|
| 669 |
+
$multiply: [
|
| 670 |
+
{ $ifNull: ['$productInfo.costPrice', 0] },
|
| 671 |
+
'$items.quantity',
|
| 672 |
+
],
|
| 673 |
+
},
|
| 674 |
+
},
|
| 675 |
+
orderCount: { $sum: 1 },
|
| 676 |
+
},
|
| 677 |
+
},
|
| 678 |
+
{ $sort: { _id: 1 } },
|
| 679 |
+
]);
|
| 680 |
+
|
| 681 |
+
// Get monthly expenses
|
| 682 |
+
const monthlyExpenses = await Expense.aggregate([
|
| 683 |
+
{
|
| 684 |
+
$match: {
|
| 685 |
+
date: { $gte: startOfYear, $lte: endOfYear },
|
| 686 |
+
},
|
| 687 |
+
},
|
| 688 |
+
{
|
| 689 |
+
$group: {
|
| 690 |
+
_id: { $month: '$date' },
|
| 691 |
+
totalExpenses: { $sum: '$amount' },
|
| 692 |
+
},
|
| 693 |
+
},
|
| 694 |
+
{ $sort: { _id: 1 } },
|
| 695 |
+
]);
|
| 696 |
+
|
| 697 |
+
const expenseMap = {};
|
| 698 |
+
monthlyExpenses.forEach((e) => {
|
| 699 |
+
expenseMap[e._id] = e.totalExpenses;
|
| 700 |
+
});
|
| 701 |
+
|
| 702 |
+
// Build full 12-month response
|
| 703 |
+
const months = [];
|
| 704 |
+
for (let m = 1; m <= 12; m++) {
|
| 705 |
+
const data = monthlyData.find((d) => d._id === m);
|
| 706 |
+
const revenue = data ? data.totalRevenue : 0;
|
| 707 |
+
const cost = data ? data.totalCost : 0;
|
| 708 |
+
const profit = revenue - cost;
|
| 709 |
+
const expenses = expenseMap[m] || 0;
|
| 710 |
+
|
| 711 |
+
months.push({
|
| 712 |
+
month: m,
|
| 713 |
+
revenue,
|
| 714 |
+
profit,
|
| 715 |
+
expenses,
|
| 716 |
+
netProfit: profit - expenses,
|
| 717 |
+
orderCount: data ? data.orderCount : 0,
|
| 718 |
+
});
|
| 719 |
+
}
|
| 720 |
+
|
| 721 |
+
res.status(200).json({
|
| 722 |
+
status: 'success',
|
| 723 |
+
data: { year: targetYear, months },
|
| 724 |
+
});
|
| 725 |
+
} catch (err) {
|
| 726 |
+
res.status(500).json({ status: 'error', message: err.message });
|
| 727 |
+
}
|
| 728 |
+
};
|
| 729 |
+
|
| 730 |
+
// GET /finance/admin/analytics/vendor-comparison
|
| 731 |
+
exports.getVendorComparison = async (req, res) => {
|
| 732 |
+
try {
|
| 733 |
+
const { startDate, endDate } = req.query;
|
| 734 |
+
const { vendorMap } = await calculateFinancials(startDate, endDate);
|
| 735 |
+
|
| 736 |
+
const allProviders = await getExternalProviders();
|
| 737 |
+
|
| 738 |
+
const comparison = [];
|
| 739 |
+
for (const provider of allProviders) {
|
| 740 |
+
const pid = provider._id.toString();
|
| 741 |
+
const stats = vendorMap[pid] || {
|
| 742 |
+
totalDeliveredSales: 0,
|
| 743 |
+
totalRevenueOwed: 0,
|
| 744 |
+
totalPurchaseCost: 0,
|
| 745 |
+
};
|
| 746 |
+
const platformCommission =
|
| 747 |
+
stats.totalDeliveredSales - stats.totalRevenueOwed;
|
| 748 |
+
const vendorProfit = stats.totalRevenueOwed - stats.totalPurchaseCost;
|
| 749 |
+
|
| 750 |
+
comparison.push({
|
| 751 |
+
provider: {
|
| 752 |
+
_id: provider._id,
|
| 753 |
+
name: provider.name,
|
| 754 |
+
storeName: provider.storeName,
|
| 755 |
+
},
|
| 756 |
+
totalSales: stats.totalDeliveredSales,
|
| 757 |
+
vendorProfit,
|
| 758 |
+
platformCommission,
|
| 759 |
+
});
|
| 760 |
+
}
|
| 761 |
+
|
| 762 |
+
// Sort by total sales descending
|
| 763 |
+
comparison.sort((a, b) => b.totalSales - a.totalSales);
|
| 764 |
+
|
| 765 |
+
res.status(200).json({
|
| 766 |
+
status: 'success',
|
| 767 |
+
data: { comparison },
|
| 768 |
+
});
|
| 769 |
+
} catch (err) {
|
| 770 |
+
res.status(500).json({ status: 'error', message: err.message });
|
| 771 |
+
}
|
| 772 |
+
};
|
| 773 |
+
|
| 774 |
+
// GET /finance/admin/analytics/revenue-vs-expenses
|
| 775 |
+
exports.getRevenueVsExpenses = async (req, res) => {
|
| 776 |
+
try {
|
| 777 |
+
const { year } = req.query;
|
| 778 |
+
const targetYear = parseInt(year, 10) || new Date().getFullYear();
|
| 779 |
+
|
| 780 |
+
const startOfYear = new Date(targetYear, 0, 1);
|
| 781 |
+
const endOfYear = new Date(targetYear, 11, 31, 23, 59, 59);
|
| 782 |
+
|
| 783 |
+
// Monthly revenue from completed orders
|
| 784 |
+
const monthlyRevenue = await Order.aggregate([
|
| 785 |
+
{
|
| 786 |
+
$match: {
|
| 787 |
+
orderStatus: 'completed',
|
| 788 |
+
createdAt: { $gte: startOfYear, $lte: endOfYear },
|
| 789 |
+
},
|
| 790 |
+
},
|
| 791 |
+
{
|
| 792 |
+
$group: {
|
| 793 |
+
_id: { $month: '$createdAt' },
|
| 794 |
+
revenue: { $sum: '$totalPrice' },
|
| 795 |
+
},
|
| 796 |
+
},
|
| 797 |
+
{ $sort: { _id: 1 } },
|
| 798 |
+
]);
|
| 799 |
+
|
| 800 |
+
// Monthly expenses
|
| 801 |
+
const monthlyExpenses = await Expense.aggregate([
|
| 802 |
+
{
|
| 803 |
+
$match: {
|
| 804 |
+
date: { $gte: startOfYear, $lte: endOfYear },
|
| 805 |
+
},
|
| 806 |
+
},
|
| 807 |
+
{
|
| 808 |
+
$group: {
|
| 809 |
+
_id: { $month: '$date' },
|
| 810 |
+
expenses: { $sum: '$amount' },
|
| 811 |
+
},
|
| 812 |
+
},
|
| 813 |
+
{ $sort: { _id: 1 } },
|
| 814 |
+
]);
|
| 815 |
+
|
| 816 |
+
// Monthly vendor payouts
|
| 817 |
+
const monthlyPayouts = await Transaction.aggregate([
|
| 818 |
+
{
|
| 819 |
+
$match: {
|
| 820 |
+
type: 'payout',
|
| 821 |
+
date: { $gte: startOfYear, $lte: endOfYear },
|
| 822 |
+
},
|
| 823 |
+
},
|
| 824 |
+
{
|
| 825 |
+
$group: {
|
| 826 |
+
_id: { $month: '$date' },
|
| 827 |
+
payouts: { $sum: '$amount' },
|
| 828 |
+
},
|
| 829 |
+
},
|
| 830 |
+
{ $sort: { _id: 1 } },
|
| 831 |
+
]);
|
| 832 |
+
|
| 833 |
+
const revenueMap = {};
|
| 834 |
+
monthlyRevenue.forEach((r) => {
|
| 835 |
+
revenueMap[r._id] = r.revenue;
|
| 836 |
+
});
|
| 837 |
+
const expenseMap = {};
|
| 838 |
+
monthlyExpenses.forEach((e) => {
|
| 839 |
+
expenseMap[e._id] = e.expenses;
|
| 840 |
+
});
|
| 841 |
+
const payoutMap = {};
|
| 842 |
+
monthlyPayouts.forEach((p) => {
|
| 843 |
+
payoutMap[p._id] = p.payouts;
|
| 844 |
+
});
|
| 845 |
+
|
| 846 |
+
const months = [];
|
| 847 |
+
for (let m = 1; m <= 12; m++) {
|
| 848 |
+
months.push({
|
| 849 |
+
month: m,
|
| 850 |
+
revenue: revenueMap[m] || 0,
|
| 851 |
+
expenses: expenseMap[m] || 0,
|
| 852 |
+
vendorPayouts: payoutMap[m] || 0,
|
| 853 |
+
totalOutflow: (expenseMap[m] || 0) + (payoutMap[m] || 0),
|
| 854 |
+
});
|
| 855 |
+
}
|
| 856 |
+
|
| 857 |
+
res.status(200).json({
|
| 858 |
+
status: 'success',
|
| 859 |
+
data: { year: targetYear, months },
|
| 860 |
+
});
|
| 861 |
+
} catch (err) {
|
| 862 |
+
res.status(500).json({ status: 'error', message: err.message });
|
| 863 |
+
}
|
| 864 |
+
};
|
| 865 |
+
|
| 866 |
+
// GET /finance/admin/summary
|
| 867 |
+
// Daily / Weekly / Monthly summary
|
| 868 |
+
exports.getFinancialSummary = async (req, res) => {
|
| 869 |
+
try {
|
| 870 |
+
const { period } = req.query; // 'daily', 'weekly', 'monthly'
|
| 871 |
+
const now = new Date();
|
| 872 |
+
let startDate;
|
| 873 |
+
|
| 874 |
+
switch (period) {
|
| 875 |
+
case 'daily':
|
| 876 |
+
startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
| 877 |
+
break;
|
| 878 |
+
case 'weekly':
|
| 879 |
+
startDate = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
| 880 |
+
break;
|
| 881 |
+
case 'monthly':
|
| 882 |
+
startDate = new Date(now.getFullYear(), now.getMonth(), 1);
|
| 883 |
+
break;
|
| 884 |
+
default:
|
| 885 |
+
startDate = new Date(now.getFullYear(), now.getMonth(), 1); // default monthly
|
| 886 |
+
}
|
| 887 |
+
|
| 888 |
+
const { totalWebsiteProfit, totalRevenue, vendorMap } =
|
| 889 |
+
await calculateFinancials(startDate, now);
|
| 890 |
+
|
| 891 |
+
// Expenses in period
|
| 892 |
+
const expenses = await Expense.aggregate([
|
| 893 |
+
{ $match: { date: { $gte: startDate, $lte: now } } },
|
| 894 |
+
{ $group: { _id: null, total: { $sum: '$amount' } } },
|
| 895 |
+
]);
|
| 896 |
+
const totalExpenses = (expenses[0] && expenses[0].total) || 0;
|
| 897 |
+
|
| 898 |
+
// Payouts in period
|
| 899 |
+
const payouts = await Transaction.aggregate([
|
| 900 |
+
{ $match: { type: 'payout', date: { $gte: startDate, $lte: now } } },
|
| 901 |
+
{ $group: { _id: null, total: { $sum: '$amount' } } },
|
| 902 |
+
]);
|
| 903 |
+
const totalPayouts = (payouts[0] && payouts[0].total) || 0;
|
| 904 |
+
|
| 905 |
+
// Order count
|
| 906 |
+
const orderCount = await Order.countDocuments({
|
| 907 |
+
orderStatus: 'completed',
|
| 908 |
+
createdAt: { $gte: startDate, $lte: now },
|
| 909 |
+
});
|
| 910 |
+
|
| 911 |
+
res.status(200).json({
|
| 912 |
+
status: 'success',
|
| 913 |
+
data: {
|
| 914 |
+
period: period || 'monthly',
|
| 915 |
+
startDate,
|
| 916 |
+
endDate: now,
|
| 917 |
+
totalRevenue,
|
| 918 |
+
totalProfit: totalWebsiteProfit,
|
| 919 |
+
totalExpenses,
|
| 920 |
+
totalPayouts,
|
| 921 |
+
netProfit: totalWebsiteProfit - totalExpenses,
|
| 922 |
+
orderCount,
|
| 923 |
+
},
|
| 924 |
+
});
|
| 925 |
+
} catch (err) {
|
| 926 |
+
res.status(500).json({ status: 'error', message: err.message });
|
| 927 |
+
}
|
| 928 |
+
};
|
| 929 |
+
|
| 930 |
+
// GET /finance/admin/export
|
| 931 |
+
// Export financial report as Excel
|
| 932 |
+
exports.exportFinancialReport = async (req, res) => {
|
| 933 |
+
try {
|
| 934 |
+
const XLSX = require('xlsx');
|
| 935 |
+
const { startDate, endDate } = req.query;
|
| 936 |
+
|
| 937 |
+
const {
|
| 938 |
+
totalWebsiteProfit,
|
| 939 |
+
websiteOwnedProfit,
|
| 940 |
+
vendorOwnedProfit,
|
| 941 |
+
totalRevenue,
|
| 942 |
+
vendorMap,
|
| 943 |
+
} = await calculateFinancials(startDate, endDate);
|
| 944 |
+
|
| 945 |
+
const allProviders = await getExternalProviders();
|
| 946 |
+
|
| 947 |
+
// Build vendor summary data
|
| 948 |
+
const payoutQuery = { type: 'payout' };
|
| 949 |
+
if (startDate || endDate) {
|
| 950 |
+
payoutQuery.date = {};
|
| 951 |
+
if (startDate) payoutQuery.date.$gte = new Date(startDate);
|
| 952 |
+
if (endDate) payoutQuery.date.$lte = new Date(endDate);
|
| 953 |
+
}
|
| 954 |
+
const payouts = await Transaction.find(payoutQuery);
|
| 955 |
+
const payoutMap = {};
|
| 956 |
+
payouts.forEach((t) => {
|
| 957 |
+
const pid = t.provider.toString();
|
| 958 |
+
payoutMap[pid] = (payoutMap[pid] || 0) + t.amount;
|
| 959 |
+
});
|
| 960 |
+
|
| 961 |
+
// Sheet 1: Overview
|
| 962 |
+
const overviewData = [
|
| 963 |
+
['Financial Report'],
|
| 964 |
+
['Period', startDate || 'All Time', endDate || 'Present'],
|
| 965 |
+
[],
|
| 966 |
+
['Total Revenue', totalRevenue],
|
| 967 |
+
['Total Platform Profit', totalWebsiteProfit],
|
| 968 |
+
['Website-Owned Profit', websiteOwnedProfit],
|
| 969 |
+
['Vendor-Owned Profit (Commission)', vendorOwnedProfit],
|
| 970 |
+
];
|
| 971 |
+
|
| 972 |
+
// Sheet 2: Vendor Details
|
| 973 |
+
const vendorHeaders = [
|
| 974 |
+
'Vendor Name',
|
| 975 |
+
'Store Name',
|
| 976 |
+
'Total Sales',
|
| 977 |
+
'Vendor Profit',
|
| 978 |
+
'Platform Commission',
|
| 979 |
+
'Total Owed',
|
| 980 |
+
'Paid Amount',
|
| 981 |
+
'Remaining Balance',
|
| 982 |
+
];
|
| 983 |
+
const vendorRows = [vendorHeaders];
|
| 984 |
+
|
| 985 |
+
for (const provider of allProviders) {
|
| 986 |
+
const pid = provider._id.toString();
|
| 987 |
+
const stats = vendorMap[pid] || {
|
| 988 |
+
totalDeliveredSales: 0,
|
| 989 |
+
totalRevenueOwed: 0,
|
| 990 |
+
totalPurchaseCost: 0,
|
| 991 |
+
};
|
| 992 |
+
const paidAmount = payoutMap[pid] || 0;
|
| 993 |
+
const vendorProfit = stats.totalRevenueOwed - stats.totalPurchaseCost;
|
| 994 |
+
const platformCommission =
|
| 995 |
+
stats.totalDeliveredSales - stats.totalRevenueOwed;
|
| 996 |
+
|
| 997 |
+
vendorRows.push([
|
| 998 |
+
provider.name,
|
| 999 |
+
provider.storeName,
|
| 1000 |
+
stats.totalDeliveredSales,
|
| 1001 |
+
vendorProfit,
|
| 1002 |
+
platformCommission,
|
| 1003 |
+
stats.totalRevenueOwed,
|
| 1004 |
+
paidAmount,
|
| 1005 |
+
stats.totalRevenueOwed - paidAmount,
|
| 1006 |
+
]);
|
| 1007 |
+
}
|
| 1008 |
+
|
| 1009 |
+
// Sheet 3: Expenses
|
| 1010 |
+
const expenseQuery = {};
|
| 1011 |
+
if (startDate || endDate) {
|
| 1012 |
+
expenseQuery.date = {};
|
| 1013 |
+
if (startDate) expenseQuery.date.$gte = new Date(startDate);
|
| 1014 |
+
if (endDate) expenseQuery.date.$lte = new Date(endDate);
|
| 1015 |
+
}
|
| 1016 |
+
const expensesList = await Expense.find(expenseQuery)
|
| 1017 |
+
.sort('-date')
|
| 1018 |
+
.populate('createdBy', 'name');
|
| 1019 |
+
const expenseHeaders = [
|
| 1020 |
+
'Category',
|
| 1021 |
+
'Amount',
|
| 1022 |
+
'Date',
|
| 1023 |
+
'Notes',
|
| 1024 |
+
'Created By',
|
| 1025 |
+
];
|
| 1026 |
+
const expenseRows = [expenseHeaders];
|
| 1027 |
+
for (const exp of expensesList) {
|
| 1028 |
+
expenseRows.push([
|
| 1029 |
+
exp.category,
|
| 1030 |
+
exp.amount,
|
| 1031 |
+
exp.date ? exp.date.toISOString().split('T')[0] : '',
|
| 1032 |
+
exp.notes,
|
| 1033 |
+
exp.createdBy ? exp.createdBy.name : '',
|
| 1034 |
+
]);
|
| 1035 |
+
}
|
| 1036 |
+
|
| 1037 |
+
// Build workbook
|
| 1038 |
+
const wb = XLSX.utils.book_new();
|
| 1039 |
+
const ws1 = XLSX.utils.aoa_to_sheet(overviewData);
|
| 1040 |
+
const ws2 = XLSX.utils.aoa_to_sheet(vendorRows);
|
| 1041 |
+
const ws3 = XLSX.utils.aoa_to_sheet(expenseRows);
|
| 1042 |
+
|
| 1043 |
+
XLSX.utils.book_append_sheet(wb, ws1, 'Overview');
|
| 1044 |
+
XLSX.utils.book_append_sheet(wb, ws2, 'Vendors');
|
| 1045 |
+
XLSX.utils.book_append_sheet(wb, ws3, 'Expenses');
|
| 1046 |
+
|
| 1047 |
+
const buffer = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' });
|
| 1048 |
+
|
| 1049 |
+
res.setHeader(
|
| 1050 |
+
'Content-Type',
|
| 1051 |
+
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
| 1052 |
+
);
|
| 1053 |
+
res.setHeader(
|
| 1054 |
+
'Content-Disposition',
|
| 1055 |
+
'attachment; filename=financial_report.xlsx',
|
| 1056 |
+
);
|
| 1057 |
+
res.send(buffer);
|
| 1058 |
+
} catch (err) {
|
| 1059 |
+
res.status(500).json({ status: 'error', message: err.message });
|
| 1060 |
+
}
|
| 1061 |
+
};
|
| 1062 |
+
|
| 1063 |
+
// ═════════════════════════════════════════════
|
| 1064 |
+
// VENDOR ENDPOINTS
|
| 1065 |
+
// ═════════════════════════════════════════════
|
| 1066 |
+
|
| 1067 |
+
// ─────────────────────────────────────────────
|
| 1068 |
+
// GET /finance/vendor/stats
|
| 1069 |
+
// Vendor Profit Overview
|
| 1070 |
+
// ─────────────────────────────────────────────
|
| 1071 |
+
exports.getVendorMyStats = async (req, res) => {
|
| 1072 |
+
try {
|
| 1073 |
+
if (!req.user.provider) {
|
| 1074 |
+
return res
|
| 1075 |
+
.status(400)
|
| 1076 |
+
.json({ status: 'fail', message: 'User is not linked to a provider' });
|
| 1077 |
+
}
|
| 1078 |
+
const myProviderId = req.user.provider.toString();
|
| 1079 |
+
const { startDate, endDate } = req.query;
|
| 1080 |
+
|
| 1081 |
+
const { vendorMap } = await calculateFinancials(startDate, endDate);
|
| 1082 |
+
const myStats = vendorMap[myProviderId] || {
|
| 1083 |
+
totalDeliveredSales: 0,
|
| 1084 |
+
totalRevenueOwed: 0,
|
| 1085 |
+
totalPurchaseCost: 0,
|
| 1086 |
+
};
|
| 1087 |
+
|
| 1088 |
+
const totalProfit = myStats.totalRevenueOwed - myStats.totalPurchaseCost;
|
| 1089 |
+
const platformCommission =
|
| 1090 |
+
myStats.totalDeliveredSales - myStats.totalRevenueOwed;
|
| 1091 |
+
|
| 1092 |
+
// Get payouts
|
| 1093 |
+
const payoutQuery = { provider: myProviderId, type: 'payout' };
|
| 1094 |
+
if (startDate || endDate) {
|
| 1095 |
+
payoutQuery.date = {};
|
| 1096 |
+
if (startDate) payoutQuery.date.$gte = new Date(startDate);
|
| 1097 |
+
if (endDate) payoutQuery.date.$lte = new Date(endDate);
|
| 1098 |
+
}
|
| 1099 |
+
const payouts = await Transaction.find(payoutQuery).sort('-date');
|
| 1100 |
+
const paidAmount = payouts.reduce((sum, t) => sum + t.amount, 0);
|
| 1101 |
+
|
| 1102 |
+
res.status(200).json({
|
| 1103 |
+
status: 'success',
|
| 1104 |
+
data: {
|
| 1105 |
+
totalSales: myStats.totalDeliveredSales,
|
| 1106 |
+
totalRevenueOwed: myStats.totalRevenueOwed,
|
| 1107 |
+
totalProfit,
|
| 1108 |
+
platformCommission,
|
| 1109 |
+
paidAmount,
|
| 1110 |
+
pendingAmount: myStats.totalRevenueOwed - paidAmount,
|
| 1111 |
+
remainingBalance: myStats.totalRevenueOwed - paidAmount,
|
| 1112 |
+
transactions: payouts,
|
| 1113 |
+
},
|
| 1114 |
+
});
|
| 1115 |
+
} catch (err) {
|
| 1116 |
+
res.status(500).json({ status: 'error', message: err.message });
|
| 1117 |
+
}
|
| 1118 |
+
};
|
| 1119 |
+
|
| 1120 |
+
// ─────────────────────────────────────────────
|
| 1121 |
+
// GET /finance/vendor/orders
|
| 1122 |
+
// Vendor Orders Financial Breakdown
|
| 1123 |
+
// ─────────────────────────────────────────────
|
| 1124 |
+
exports.getVendorOrderBreakdown = async (req, res) => {
|
| 1125 |
+
try {
|
| 1126 |
+
if (!req.user.provider) {
|
| 1127 |
+
return res
|
| 1128 |
+
.status(400)
|
| 1129 |
+
.json({ status: 'fail', message: 'User is not linked to a provider' });
|
| 1130 |
+
}
|
| 1131 |
+
const myProviderId = req.user.provider.toString();
|
| 1132 |
+
const { startDate, endDate, page = 1, limit = 20 } = req.query;
|
| 1133 |
+
|
| 1134 |
+
// Find completed orders that contain products from this vendor's provider
|
| 1135 |
+
const matchStage = { orderStatus: 'completed' };
|
| 1136 |
+
if (startDate || endDate) {
|
| 1137 |
+
matchStage.createdAt = {};
|
| 1138 |
+
if (startDate) matchStage.createdAt.$gte = new Date(startDate);
|
| 1139 |
+
if (endDate) matchStage.createdAt.$lte = new Date(endDate);
|
| 1140 |
+
}
|
| 1141 |
+
|
| 1142 |
+
const orders = await Order.find(matchStage)
|
| 1143 |
+
.populate({
|
| 1144 |
+
path: 'items.product',
|
| 1145 |
+
populate: { path: 'provider' },
|
| 1146 |
+
})
|
| 1147 |
+
.sort('-createdAt');
|
| 1148 |
+
|
| 1149 |
+
// Filter and compute per-order financials for THIS vendor
|
| 1150 |
+
const orderBreakdown = [];
|
| 1151 |
+
|
| 1152 |
+
for (const order of orders) {
|
| 1153 |
+
let orderVendorSales = 0;
|
| 1154 |
+
let orderVendorCost = 0;
|
| 1155 |
+
let orderVendorProfit = 0;
|
| 1156 |
+
let orderCommission = 0;
|
| 1157 |
+
let hasVendorItems = false;
|
| 1158 |
+
|
| 1159 |
+
const vendorItems = [];
|
| 1160 |
+
|
| 1161 |
+
for (const item of order.items) {
|
| 1162 |
+
if (!item.product || !item.product.provider) continue;
|
| 1163 |
+
if (item.product.provider._id.toString() !== myProviderId) continue;
|
| 1164 |
+
|
| 1165 |
+
hasVendorItems = true;
|
| 1166 |
+
const qty = item.quantity;
|
| 1167 |
+
const sellingPrice = item.unitPrice;
|
| 1168 |
+
const costPrice = item.product.costPrice || 0;
|
| 1169 |
+
const purchasePrice = item.product.purchasePrice || 0;
|
| 1170 |
+
|
| 1171 |
+
const itemSales = sellingPrice * qty;
|
| 1172 |
+
const itemOwed = costPrice * qty;
|
| 1173 |
+
const itemProfit = (costPrice - purchasePrice) * qty;
|
| 1174 |
+
const itemCommission = (sellingPrice - costPrice) * qty;
|
| 1175 |
+
|
| 1176 |
+
orderVendorSales += itemSales;
|
| 1177 |
+
orderVendorCost += itemOwed;
|
| 1178 |
+
orderVendorProfit += itemProfit;
|
| 1179 |
+
orderCommission += itemCommission;
|
| 1180 |
+
|
| 1181 |
+
vendorItems.push({
|
| 1182 |
+
productId: item.product._id,
|
| 1183 |
+
productName: item.product.nameAr || item.product.nameEn,
|
| 1184 |
+
quantity: qty,
|
| 1185 |
+
sellingPrice,
|
| 1186 |
+
costPrice,
|
| 1187 |
+
profitPerUnit: costPrice - purchasePrice,
|
| 1188 |
+
totalProfit: itemProfit,
|
| 1189 |
+
commission: itemCommission,
|
| 1190 |
+
});
|
| 1191 |
+
}
|
| 1192 |
+
|
| 1193 |
+
if (hasVendorItems) {
|
| 1194 |
+
orderBreakdown.push({
|
| 1195 |
+
orderId: order._id,
|
| 1196 |
+
orderDate: order.createdAt,
|
| 1197 |
+
totalSales: orderVendorSales,
|
| 1198 |
+
vendorRevenue: orderVendorCost,
|
| 1199 |
+
vendorProfit: orderVendorProfit,
|
| 1200 |
+
platformCommission: orderCommission,
|
| 1201 |
+
items: vendorItems,
|
| 1202 |
+
});
|
| 1203 |
+
}
|
| 1204 |
+
}
|
| 1205 |
+
|
| 1206 |
+
// Paginate
|
| 1207 |
+
const skip = (parseInt(page, 10) - 1) * parseInt(limit, 10);
|
| 1208 |
+
const paginatedOrders = orderBreakdown.slice(
|
| 1209 |
+
skip,
|
| 1210 |
+
skip + parseInt(limit, 10),
|
| 1211 |
+
);
|
| 1212 |
+
|
| 1213 |
+
res.status(200).json({
|
| 1214 |
+
status: 'success',
|
| 1215 |
+
results: paginatedOrders.length,
|
| 1216 |
+
totalRecords: orderBreakdown.length,
|
| 1217 |
+
data: { orders: paginatedOrders },
|
| 1218 |
+
});
|
| 1219 |
+
} catch (err) {
|
| 1220 |
+
res.status(500).json({ status: 'error', message: err.message });
|
| 1221 |
+
}
|
| 1222 |
+
};
|
| 1223 |
+
|
| 1224 |
+
// ─────────────────────────────────────────────
|
| 1225 |
+
// GET /finance/vendor/payments
|
| 1226 |
+
// Vendor Payment History
|
| 1227 |
+
// ─────────────────────────────────────────────
|
| 1228 |
+
exports.getVendorPaymentHistory = async (req, res) => {
|
| 1229 |
+
try {
|
| 1230 |
+
if (!req.user.provider) {
|
| 1231 |
+
return res
|
| 1232 |
+
.status(400)
|
| 1233 |
+
.json({ status: 'fail', message: 'User is not linked to a provider' });
|
| 1234 |
+
}
|
| 1235 |
+
const myProviderId = req.user.provider.toString();
|
| 1236 |
+
const { startDate, endDate, page = 1, limit = 20 } = req.query;
|
| 1237 |
+
|
| 1238 |
+
const query = { provider: myProviderId, type: 'payout' };
|
| 1239 |
+
if (startDate || endDate) {
|
| 1240 |
+
query.date = {};
|
| 1241 |
+
if (startDate) query.date.$gte = new Date(startDate);
|
| 1242 |
+
if (endDate) query.date.$lte = new Date(endDate);
|
| 1243 |
+
}
|
| 1244 |
+
|
| 1245 |
+
const skip = (parseInt(page, 10) - 1) * parseInt(limit, 10);
|
| 1246 |
+
|
| 1247 |
+
const [payments, total] = await Promise.all([
|
| 1248 |
+
Transaction.find(query)
|
| 1249 |
+
.sort('-date')
|
| 1250 |
+
.skip(skip)
|
| 1251 |
+
.limit(parseInt(limit, 10))
|
| 1252 |
+
.populate('admin', 'name email'),
|
| 1253 |
+
Transaction.countDocuments(query),
|
| 1254 |
+
]);
|
| 1255 |
+
|
| 1256 |
+
const totalPaid = await Transaction.aggregate([
|
| 1257 |
+
{ $match: query },
|
| 1258 |
+
{ $group: { _id: null, total: { $sum: '$amount' } } },
|
| 1259 |
+
]);
|
| 1260 |
+
|
| 1261 |
+
res.status(200).json({
|
| 1262 |
+
status: 'success',
|
| 1263 |
+
results: payments.length,
|
| 1264 |
+
totalRecords: total,
|
| 1265 |
+
data: {
|
| 1266 |
+
payments,
|
| 1267 |
+
totalPaid: (totalPaid[0] && totalPaid[0].total) || 0,
|
| 1268 |
+
},
|
| 1269 |
+
});
|
| 1270 |
+
} catch (err) {
|
| 1271 |
+
res.status(500).json({ status: 'error', message: err.message });
|
| 1272 |
+
}
|
| 1273 |
+
};
|
| 1274 |
+
|
| 1275 |
+
// ─────────────────────────────────────────────
|
| 1276 |
+
// GET /finance/vendor/analytics
|
| 1277 |
+
// Vendor profit analytics (daily/monthly)
|
| 1278 |
+
// ─────────────────────────────────────────────
|
| 1279 |
+
exports.getVendorAnalytics = async (req, res) => {
|
| 1280 |
+
try {
|
| 1281 |
+
if (!req.user.provider) {
|
| 1282 |
+
return res
|
| 1283 |
+
.status(400)
|
| 1284 |
+
.json({ status: 'fail', message: 'User is not linked to a provider' });
|
| 1285 |
+
}
|
| 1286 |
+
const myProviderId = req.user.provider.toString();
|
| 1287 |
+
const { year, granularity } = req.query; // granularity: 'daily' or 'monthly'
|
| 1288 |
+
const targetYear = parseInt(year, 10) || new Date().getFullYear();
|
| 1289 |
+
|
| 1290 |
+
const startOfYear = new Date(targetYear, 0, 1);
|
| 1291 |
+
const endOfYear = new Date(targetYear, 11, 31, 23, 59, 59);
|
| 1292 |
+
|
| 1293 |
+
// Get completed orders within the year
|
| 1294 |
+
const orders = await Order.find({
|
| 1295 |
+
orderStatus: 'completed',
|
| 1296 |
+
createdAt: { $gte: startOfYear, $lte: endOfYear },
|
| 1297 |
+
}).populate({
|
| 1298 |
+
path: 'items.product',
|
| 1299 |
+
populate: { path: 'provider' },
|
| 1300 |
+
});
|
| 1301 |
+
|
| 1302 |
+
// Group by time period
|
| 1303 |
+
const dataMap = {};
|
| 1304 |
+
|
| 1305 |
+
for (const order of orders) {
|
| 1306 |
+
for (const item of order.items) {
|
| 1307 |
+
if (!item.product || !item.product.provider) continue;
|
| 1308 |
+
if (item.product.provider._id.toString() !== myProviderId) continue;
|
| 1309 |
+
|
| 1310 |
+
const date = new Date(order.createdAt);
|
| 1311 |
+
let key;
|
| 1312 |
+
|
| 1313 |
+
if (granularity === 'daily') {
|
| 1314 |
+
key = date.toISOString().split('T')[0]; // YYYY-MM-DD
|
| 1315 |
+
} else {
|
| 1316 |
+
key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`; // YYYY-MM
|
| 1317 |
+
}
|
| 1318 |
+
|
| 1319 |
+
if (!dataMap[key]) {
|
| 1320 |
+
dataMap[key] = { sales: 0, revenue: 0, profit: 0, orders: new Set() };
|
| 1321 |
+
}
|
| 1322 |
+
|
| 1323 |
+
const qty = item.quantity;
|
| 1324 |
+
const sellingPrice = item.unitPrice;
|
| 1325 |
+
const costPrice = item.product.costPrice || 0;
|
| 1326 |
+
const purchasePrice = item.product.purchasePrice || 0;
|
| 1327 |
+
|
| 1328 |
+
dataMap[key].sales += sellingPrice * qty;
|
| 1329 |
+
dataMap[key].revenue += costPrice * qty;
|
| 1330 |
+
dataMap[key].profit += (costPrice - purchasePrice) * qty;
|
| 1331 |
+
dataMap[key].orders.add(order._id.toString());
|
| 1332 |
+
}
|
| 1333 |
+
}
|
| 1334 |
+
|
| 1335 |
+
// Convert to array
|
| 1336 |
+
const analytics = Object.entries(dataMap)
|
| 1337 |
+
.map(([period, data]) => ({
|
| 1338 |
+
period,
|
| 1339 |
+
sales: data.sales,
|
| 1340 |
+
revenue: data.revenue,
|
| 1341 |
+
profit: data.profit,
|
| 1342 |
+
orderCount: data.orders.size,
|
| 1343 |
+
}))
|
| 1344 |
+
.sort((a, b) => a.period.localeCompare(b.period));
|
| 1345 |
+
|
| 1346 |
+
res.status(200).json({
|
| 1347 |
+
status: 'success',
|
| 1348 |
+
data: {
|
| 1349 |
+
year: targetYear,
|
| 1350 |
+
granularity: granularity || 'monthly',
|
| 1351 |
+
analytics,
|
| 1352 |
+
},
|
| 1353 |
+
});
|
| 1354 |
+
} catch (err) {
|
| 1355 |
+
res.status(500).json({ status: 'error', message: err.message });
|
| 1356 |
+
}
|
| 1357 |
+
};
|
| 1358 |
+
|
| 1359 |
+
// ═════════════════════════════════════════════
|
| 1360 |
+
// CAPITAL ACCOUNTS (Where is the money?)
|
| 1361 |
+
// ═════════════════════════════════════════════
|
| 1362 |
+
|
| 1363 |
+
// GET /finance/admin/accounts
|
| 1364 |
+
// List all capital accounts with balances
|
| 1365 |
+
exports.getCapitalAccounts = async (req, res) => {
|
| 1366 |
+
try {
|
| 1367 |
+
const { includeInactive } = req.query;
|
| 1368 |
+
const query = includeInactive === 'true' ? {} : { isActive: true };
|
| 1369 |
+
|
| 1370 |
+
const accounts = await CapitalAccount.find(query).sort('type name');
|
| 1371 |
+
|
| 1372 |
+
// Totals by type
|
| 1373 |
+
const totals = {
|
| 1374 |
+
cash: 0,
|
| 1375 |
+
bank: 0,
|
| 1376 |
+
e_wallet: 0,
|
| 1377 |
+
other: 0,
|
| 1378 |
+
grand: 0,
|
| 1379 |
+
};
|
| 1380 |
+
|
| 1381 |
+
for (const acc of accounts) {
|
| 1382 |
+
if (!acc.isActive) continue;
|
| 1383 |
+
totals[acc.type] = (totals[acc.type] || 0) + acc.balance;
|
| 1384 |
+
totals.grand += acc.balance;
|
| 1385 |
+
}
|
| 1386 |
+
|
| 1387 |
+
res.status(200).json({
|
| 1388 |
+
status: 'success',
|
| 1389 |
+
results: accounts.length,
|
| 1390 |
+
data: {
|
| 1391 |
+
accounts,
|
| 1392 |
+
totals,
|
| 1393 |
+
},
|
| 1394 |
+
});
|
| 1395 |
+
} catch (err) {
|
| 1396 |
+
res.status(500).json({ status: 'error', message: err.message });
|
| 1397 |
+
}
|
| 1398 |
+
};
|
| 1399 |
+
|
| 1400 |
+
// POST /finance/admin/accounts
|
| 1401 |
+
// Create a new capital account
|
| 1402 |
+
exports.createCapitalAccount = async (req, res) => {
|
| 1403 |
+
try {
|
| 1404 |
+
const { name, type, bankName, accountNumber, balance, notes } = req.body;
|
| 1405 |
+
|
| 1406 |
+
if (!name || !type) {
|
| 1407 |
+
return res
|
| 1408 |
+
.status(400)
|
| 1409 |
+
.json({ status: 'fail', message: 'name and type are required' });
|
| 1410 |
+
}
|
| 1411 |
+
|
| 1412 |
+
const account = await CapitalAccount.create({
|
| 1413 |
+
name,
|
| 1414 |
+
type,
|
| 1415 |
+
bankName: bankName || '',
|
| 1416 |
+
accountNumber: accountNumber || '',
|
| 1417 |
+
balance: balance || 0,
|
| 1418 |
+
notes: notes || '',
|
| 1419 |
+
});
|
| 1420 |
+
|
| 1421 |
+
// If initial balance > 0, log it in capital
|
| 1422 |
+
if (balance && balance > 0) {
|
| 1423 |
+
const capital = await Capital.getCapital();
|
| 1424 |
+
capital.logs.push({
|
| 1425 |
+
type: 'adjustment',
|
| 1426 |
+
amount: balance,
|
| 1427 |
+
balanceAfter: capital.currentCapital,
|
| 1428 |
+
description: `Initial balance for new account "${name}" (${type})`,
|
| 1429 |
+
date: new Date(),
|
| 1430 |
+
createdBy: req.user._id,
|
| 1431 |
+
});
|
| 1432 |
+
await capital.save();
|
| 1433 |
+
}
|
| 1434 |
+
|
| 1435 |
+
res.status(201).json({
|
| 1436 |
+
status: 'success',
|
| 1437 |
+
data: { account },
|
| 1438 |
+
});
|
| 1439 |
+
} catch (err) {
|
| 1440 |
+
res.status(500).json({ status: 'error', message: err.message });
|
| 1441 |
+
}
|
| 1442 |
+
};
|
| 1443 |
+
|
| 1444 |
+
// PUT /finance/admin/accounts/:id
|
| 1445 |
+
// Update a capital account (name, type, bankName, notes, isActive)
|
| 1446 |
+
exports.updateCapitalAccount = async (req, res) => {
|
| 1447 |
+
try {
|
| 1448 |
+
const account = await CapitalAccount.findById(req.params.id);
|
| 1449 |
+
if (!account) {
|
| 1450 |
+
return res
|
| 1451 |
+
.status(404)
|
| 1452 |
+
.json({ status: 'fail', message: 'Account not found' });
|
| 1453 |
+
}
|
| 1454 |
+
|
| 1455 |
+
const allowedFields = [
|
| 1456 |
+
'name',
|
| 1457 |
+
'type',
|
| 1458 |
+
'bankName',
|
| 1459 |
+
'accountNumber',
|
| 1460 |
+
'notes',
|
| 1461 |
+
'isActive',
|
| 1462 |
+
];
|
| 1463 |
+
for (const field of allowedFields) {
|
| 1464 |
+
if (req.body[field] !== undefined) {
|
| 1465 |
+
account[field] = req.body[field];
|
| 1466 |
+
}
|
| 1467 |
+
}
|
| 1468 |
+
|
| 1469 |
+
await account.save();
|
| 1470 |
+
|
| 1471 |
+
res.status(200).json({
|
| 1472 |
+
status: 'success',
|
| 1473 |
+
data: { account },
|
| 1474 |
+
});
|
| 1475 |
+
} catch (err) {
|
| 1476 |
+
res.status(500).json({ status: 'error', message: err.message });
|
| 1477 |
+
}
|
| 1478 |
+
};
|
| 1479 |
+
|
| 1480 |
+
// DELETE /finance/admin/accounts/:id
|
| 1481 |
+
// Soft-delete (deactivate) an account — only if balance is 0
|
| 1482 |
+
exports.deleteCapitalAccount = async (req, res) => {
|
| 1483 |
+
try {
|
| 1484 |
+
const account = await CapitalAccount.findById(req.params.id);
|
| 1485 |
+
if (!account) {
|
| 1486 |
+
return res
|
| 1487 |
+
.status(404)
|
| 1488 |
+
.json({ status: 'fail', message: 'Account not found' });
|
| 1489 |
+
}
|
| 1490 |
+
|
| 1491 |
+
if (account.balance !== 0) {
|
| 1492 |
+
return res.status(400).json({
|
| 1493 |
+
status: 'fail',
|
| 1494 |
+
message: `Cannot delete account with non-zero balance (${account.balance}). Transfer the balance first.`,
|
| 1495 |
+
});
|
| 1496 |
+
}
|
| 1497 |
+
|
| 1498 |
+
account.isActive = false;
|
| 1499 |
+
await account.save();
|
| 1500 |
+
|
| 1501 |
+
res.status(200).json({
|
| 1502 |
+
status: 'success',
|
| 1503 |
+
message: 'Account deactivated successfully',
|
| 1504 |
+
});
|
| 1505 |
+
} catch (err) {
|
| 1506 |
+
res.status(500).json({ status: 'error', message: err.message });
|
| 1507 |
+
}
|
| 1508 |
+
};
|
| 1509 |
+
|
| 1510 |
+
// PUT /finance/admin/accounts/:id/balance
|
| 1511 |
+
// Manually set/correct the balance of an account (e.g. after physical count)
|
| 1512 |
+
exports.setAccountBalance = async (req, res) => {
|
| 1513 |
+
try {
|
| 1514 |
+
const { balance, note } = req.body;
|
| 1515 |
+
|
| 1516 |
+
if (balance === undefined || balance < 0) {
|
| 1517 |
+
return res.status(400).json({
|
| 1518 |
+
status: 'fail',
|
| 1519 |
+
message: 'balance must be a non-negative number',
|
| 1520 |
+
});
|
| 1521 |
+
}
|
| 1522 |
+
|
| 1523 |
+
const account = await CapitalAccount.findById(req.params.id);
|
| 1524 |
+
if (!account) {
|
| 1525 |
+
return res
|
| 1526 |
+
.status(404)
|
| 1527 |
+
.json({ status: 'fail', message: 'Account not found' });
|
| 1528 |
+
}
|
| 1529 |
+
|
| 1530 |
+
const oldBalance = account.balance;
|
| 1531 |
+
const diff = balance - oldBalance;
|
| 1532 |
+
account.balance = balance;
|
| 1533 |
+
await account.save();
|
| 1534 |
+
|
| 1535 |
+
// Log the adjustment in capital
|
| 1536 |
+
if (diff !== 0) {
|
| 1537 |
+
const capital = await Capital.getCapital();
|
| 1538 |
+
capital.currentCapital += diff;
|
| 1539 |
+
capital.logs.push({
|
| 1540 |
+
type: 'adjustment',
|
| 1541 |
+
amount: diff,
|
| 1542 |
+
balanceAfter: capital.currentCapital,
|
| 1543 |
+
description:
|
| 1544 |
+
`Account "${account.name}" balance corrected: ${oldBalance} → ${balance}. ${note || ''}`.trim(),
|
| 1545 |
+
date: new Date(),
|
| 1546 |
+
createdBy: req.user._id,
|
| 1547 |
+
});
|
| 1548 |
+
await capital.save();
|
| 1549 |
+
}
|
| 1550 |
+
|
| 1551 |
+
res.status(200).json({
|
| 1552 |
+
status: 'success',
|
| 1553 |
+
data: {
|
| 1554 |
+
account,
|
| 1555 |
+
adjustment: diff,
|
| 1556 |
+
},
|
| 1557 |
+
});
|
| 1558 |
+
} catch (err) {
|
| 1559 |
+
res.status(500).json({ status: 'error', message: err.message });
|
| 1560 |
+
}
|
| 1561 |
+
};
|
| 1562 |
+
|
| 1563 |
+
// ═════════════════════════════════════════════
|
| 1564 |
+
// CAPITAL TRANSFERS (Move money between accounts)
|
| 1565 |
+
// ═════════════════════════════════════════════
|
| 1566 |
+
|
| 1567 |
+
// POST /finance/admin/transfers
|
| 1568 |
+
// Transfer money from one account to another
|
| 1569 |
+
exports.createTransfer = async (req, res) => {
|
| 1570 |
+
try {
|
| 1571 |
+
const { fromAccountId, toAccountId, amount, note } = req.body;
|
| 1572 |
+
|
| 1573 |
+
if (!fromAccountId || !toAccountId) {
|
| 1574 |
+
return res.status(400).json({
|
| 1575 |
+
status: 'fail',
|
| 1576 |
+
message: 'fromAccountId and toAccountId are required',
|
| 1577 |
+
});
|
| 1578 |
+
}
|
| 1579 |
+
|
| 1580 |
+
if (fromAccountId === toAccountId) {
|
| 1581 |
+
return res.status(400).json({
|
| 1582 |
+
status: 'fail',
|
| 1583 |
+
message: 'Cannot transfer to the same account',
|
| 1584 |
+
});
|
| 1585 |
+
}
|
| 1586 |
+
|
| 1587 |
+
if (!amount || amount <= 0) {
|
| 1588 |
+
return res
|
| 1589 |
+
.status(400)
|
| 1590 |
+
.json({ status: 'fail', message: 'Amount must be positive' });
|
| 1591 |
+
}
|
| 1592 |
+
|
| 1593 |
+
const fromAccount = await CapitalAccount.findById(fromAccountId);
|
| 1594 |
+
const toAccount = await CapitalAccount.findById(toAccountId);
|
| 1595 |
+
|
| 1596 |
+
if (!fromAccount) {
|
| 1597 |
+
return res
|
| 1598 |
+
.status(404)
|
| 1599 |
+
.json({ status: 'fail', message: 'Source account not found' });
|
| 1600 |
+
}
|
| 1601 |
+
if (!toAccount) {
|
| 1602 |
+
return res
|
| 1603 |
+
.status(404)
|
| 1604 |
+
.json({ status: 'fail', message: 'Destination account not found' });
|
| 1605 |
+
}
|
| 1606 |
+
|
| 1607 |
+
if (!fromAccount.isActive || !toAccount.isActive) {
|
| 1608 |
+
return res.status(400).json({
|
| 1609 |
+
status: 'fail',
|
| 1610 |
+
message: 'Cannot transfer to/from a deactivated account',
|
| 1611 |
+
});
|
| 1612 |
+
}
|
| 1613 |
+
|
| 1614 |
+
if (fromAccount.balance < amount) {
|
| 1615 |
+
return res.status(400).json({
|
| 1616 |
+
status: 'fail',
|
| 1617 |
+
message: `Insufficient balance in "${fromAccount.name}". Available: ${fromAccount.balance}, Requested: ${amount}`,
|
| 1618 |
+
});
|
| 1619 |
+
}
|
| 1620 |
+
|
| 1621 |
+
// Perform the transfer
|
| 1622 |
+
fromAccount.balance -= amount;
|
| 1623 |
+
toAccount.balance += amount;
|
| 1624 |
+
|
| 1625 |
+
await fromAccount.save();
|
| 1626 |
+
await toAccount.save();
|
| 1627 |
+
|
| 1628 |
+
// Record the transfer
|
| 1629 |
+
const transfer = await CapitalTransfer.create({
|
| 1630 |
+
fromAccount: fromAccountId,
|
| 1631 |
+
toAccount: toAccountId,
|
| 1632 |
+
amount,
|
| 1633 |
+
note: note || '',
|
| 1634 |
+
createdBy: req.user._id,
|
| 1635 |
+
});
|
| 1636 |
+
|
| 1637 |
+
// Log in capital timeline
|
| 1638 |
+
const capital = await Capital.getCapital();
|
| 1639 |
+
capital.logs.push({
|
| 1640 |
+
type: 'adjustment',
|
| 1641 |
+
amount: 0, // net effect on total capital is zero
|
| 1642 |
+
balanceAfter: capital.currentCapital,
|
| 1643 |
+
description:
|
| 1644 |
+
`Transfer: ${amount} from "${fromAccount.name}" → "${toAccount.name}". ${note || ''}`.trim(),
|
| 1645 |
+
date: new Date(),
|
| 1646 |
+
createdBy: req.user._id,
|
| 1647 |
+
});
|
| 1648 |
+
await capital.save();
|
| 1649 |
+
|
| 1650 |
+
res.status(201).json({
|
| 1651 |
+
status: 'success',
|
| 1652 |
+
data: {
|
| 1653 |
+
transfer,
|
| 1654 |
+
fromAccount: {
|
| 1655 |
+
_id: fromAccount._id,
|
| 1656 |
+
name: fromAccount.name,
|
| 1657 |
+
balance: fromAccount.balance,
|
| 1658 |
+
},
|
| 1659 |
+
toAccount: {
|
| 1660 |
+
_id: toAccount._id,
|
| 1661 |
+
name: toAccount.name,
|
| 1662 |
+
balance: toAccount.balance,
|
| 1663 |
+
},
|
| 1664 |
+
},
|
| 1665 |
+
});
|
| 1666 |
+
} catch (err) {
|
| 1667 |
+
res.status(500).json({ status: 'error', message: err.message });
|
| 1668 |
+
}
|
| 1669 |
+
};
|
| 1670 |
+
|
| 1671 |
+
// GET /finance/admin/transfers
|
| 1672 |
+
// Get transfer history
|
| 1673 |
+
exports.getTransfers = async (req, res) => {
|
| 1674 |
+
try {
|
| 1675 |
+
const { startDate, endDate, accountId, page = 1, limit = 30 } = req.query;
|
| 1676 |
+
const query = {};
|
| 1677 |
+
|
| 1678 |
+
if (startDate || endDate) {
|
| 1679 |
+
query.date = {};
|
| 1680 |
+
if (startDate) query.date.$gte = new Date(startDate);
|
| 1681 |
+
if (endDate) query.date.$lte = new Date(endDate);
|
| 1682 |
+
}
|
| 1683 |
+
|
| 1684 |
+
// Filter by specific account (either source or destination)
|
| 1685 |
+
if (accountId) {
|
| 1686 |
+
query.$or = [{ fromAccount: accountId }, { toAccount: accountId }];
|
| 1687 |
+
}
|
| 1688 |
+
|
| 1689 |
+
const skip = (parseInt(page, 10) - 1) * parseInt(limit, 10);
|
| 1690 |
+
|
| 1691 |
+
const [transfers, total] = await Promise.all([
|
| 1692 |
+
CapitalTransfer.find(query)
|
| 1693 |
+
.sort('-date')
|
| 1694 |
+
.skip(skip)
|
| 1695 |
+
.limit(parseInt(limit, 10))
|
| 1696 |
+
.populate('fromAccount', 'name type bankName')
|
| 1697 |
+
.populate('toAccount', 'name type bankName')
|
| 1698 |
+
.populate('createdBy', 'name email'),
|
| 1699 |
+
CapitalTransfer.countDocuments(query),
|
| 1700 |
+
]);
|
| 1701 |
+
|
| 1702 |
+
res.status(200).json({
|
| 1703 |
+
status: 'success',
|
| 1704 |
+
results: transfers.length,
|
| 1705 |
+
totalRecords: total,
|
| 1706 |
+
data: { transfers },
|
| 1707 |
+
});
|
| 1708 |
+
} catch (err) {
|
| 1709 |
+
res.status(500).json({ status: 'error', message: err.message });
|
| 1710 |
+
}
|
| 1711 |
+
};
|
controllers/imageController.js
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const { deleteImage, getPublicIdFromUrl } = require('../config/cloudinary');
|
| 2 |
+
|
| 3 |
+
// Upload single image
|
| 4 |
+
exports.uploadImage = async (req, res) => {
|
| 5 |
+
try {
|
| 6 |
+
if (!req.file) {
|
| 7 |
+
return res.status(400).json({
|
| 8 |
+
status: 'fail',
|
| 9 |
+
message: 'No file uploaded',
|
| 10 |
+
});
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
res.status(200).json({
|
| 14 |
+
status: 'success',
|
| 15 |
+
data: {
|
| 16 |
+
url: req.file.path,
|
| 17 |
+
publicId: req.file.filename,
|
| 18 |
+
},
|
| 19 |
+
});
|
| 20 |
+
} catch (error) {
|
| 21 |
+
console.error('Upload error details:', error);
|
| 22 |
+
res.status(500).json({
|
| 23 |
+
status: 'error',
|
| 24 |
+
message: error.message,
|
| 25 |
+
});
|
| 26 |
+
}
|
| 27 |
+
};
|
| 28 |
+
|
| 29 |
+
// Upload multiple images
|
| 30 |
+
exports.uploadMultipleImages = async (req, res) => {
|
| 31 |
+
try {
|
| 32 |
+
if (!req.files || Object.keys(req.files).length === 0) {
|
| 33 |
+
return res.status(400).json({
|
| 34 |
+
status: 'fail',
|
| 35 |
+
message: 'No files uploaded',
|
| 36 |
+
});
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
const response = {};
|
| 40 |
+
|
| 41 |
+
// Handle imageCover
|
| 42 |
+
if (req.files.imageCover) {
|
| 43 |
+
response.imageCover = {
|
| 44 |
+
url: req.files.imageCover[0].path,
|
| 45 |
+
publicId: req.files.imageCover[0].filename,
|
| 46 |
+
};
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
// Handle additional images
|
| 50 |
+
if (req.files.images) {
|
| 51 |
+
response.images = req.files.images.map((file) => ({
|
| 52 |
+
url: file.path,
|
| 53 |
+
publicId: file.filename,
|
| 54 |
+
}));
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
res.status(200).json({
|
| 58 |
+
status: 'success',
|
| 59 |
+
data: response,
|
| 60 |
+
});
|
| 61 |
+
} catch (error) {
|
| 62 |
+
res.status(500).json({
|
| 63 |
+
status: 'error',
|
| 64 |
+
message: error.message,
|
| 65 |
+
});
|
| 66 |
+
}
|
| 67 |
+
};
|
| 68 |
+
|
| 69 |
+
// Delete image
|
| 70 |
+
exports.deleteImage = async (req, res) => {
|
| 71 |
+
try {
|
| 72 |
+
const { publicId } = req.body;
|
| 73 |
+
|
| 74 |
+
if (!publicId) {
|
| 75 |
+
return res.status(400).json({
|
| 76 |
+
status: 'fail',
|
| 77 |
+
message: 'Public ID is required',
|
| 78 |
+
});
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
const result = await deleteImage(publicId);
|
| 82 |
+
|
| 83 |
+
if (result.result === 'ok') {
|
| 84 |
+
res.status(200).json({
|
| 85 |
+
status: 'success',
|
| 86 |
+
message: 'Image deleted successfully',
|
| 87 |
+
});
|
| 88 |
+
} else {
|
| 89 |
+
res.status(404).json({
|
| 90 |
+
status: 'fail',
|
| 91 |
+
message: 'Image not found',
|
| 92 |
+
});
|
| 93 |
+
}
|
| 94 |
+
} catch (error) {
|
| 95 |
+
res.status(500).json({
|
| 96 |
+
status: 'error',
|
| 97 |
+
message: error.message,
|
| 98 |
+
});
|
| 99 |
+
}
|
| 100 |
+
};
|
| 101 |
+
|
| 102 |
+
// Delete image by URL
|
| 103 |
+
exports.deleteImageByUrl = async (req, res) => {
|
| 104 |
+
try {
|
| 105 |
+
const { url } = req.body;
|
| 106 |
+
|
| 107 |
+
if (!url) {
|
| 108 |
+
return res.status(400).json({
|
| 109 |
+
status: 'fail',
|
| 110 |
+
message: 'Image URL is required',
|
| 111 |
+
});
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
const publicId = getPublicIdFromUrl(url);
|
| 115 |
+
|
| 116 |
+
if (!publicId) {
|
| 117 |
+
return res.status(400).json({
|
| 118 |
+
status: 'fail',
|
| 119 |
+
message: 'Invalid Cloudinary URL',
|
| 120 |
+
});
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
const result = await deleteImage(publicId);
|
| 124 |
+
|
| 125 |
+
if (result.result === 'ok') {
|
| 126 |
+
res.status(200).json({
|
| 127 |
+
status: 'success',
|
| 128 |
+
message: 'Image deleted successfully',
|
| 129 |
+
});
|
| 130 |
+
} else {
|
| 131 |
+
res.status(404).json({
|
| 132 |
+
status: 'fail',
|
| 133 |
+
message: 'Image not found',
|
| 134 |
+
});
|
| 135 |
+
}
|
| 136 |
+
} catch (error) {
|
| 137 |
+
res.status(500).json({
|
| 138 |
+
status: 'error',
|
| 139 |
+
message: error.message,
|
| 140 |
+
});
|
| 141 |
+
}
|
| 142 |
+
};
|
| 143 |
+
|
| 144 |
+
// Delete entire product folder
|
| 145 |
+
exports.deleteProductFolder = async (req, res) => {
|
| 146 |
+
try {
|
| 147 |
+
const { productId } = req.params;
|
| 148 |
+
|
| 149 |
+
if (!productId) {
|
| 150 |
+
return res.status(400).json({
|
| 151 |
+
status: 'fail',
|
| 152 |
+
message: 'Product ID is required',
|
| 153 |
+
});
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
const {
|
| 157 |
+
deleteProductFolder: deleteFolderFn,
|
| 158 |
+
} = require('../config/cloudinary');
|
| 159 |
+
const result = await deleteFolderFn(productId);
|
| 160 |
+
|
| 161 |
+
res.status(200).json({
|
| 162 |
+
status: 'success',
|
| 163 |
+
message: 'Product folder deleted successfully',
|
| 164 |
+
data: result,
|
| 165 |
+
});
|
| 166 |
+
} catch (error) {
|
| 167 |
+
res.status(500).json({
|
| 168 |
+
status: 'error',
|
| 169 |
+
message: error.message,
|
| 170 |
+
});
|
| 171 |
+
}
|
| 172 |
+
};
|
| 173 |
+
|
| 174 |
+
// Delete entire hero slide folder
|
| 175 |
+
exports.deleteHeroSlideFolder = async (req, res) => {
|
| 176 |
+
try {
|
| 177 |
+
const { slideId } = req.params;
|
| 178 |
+
|
| 179 |
+
if (!slideId) {
|
| 180 |
+
return res.status(400).json({
|
| 181 |
+
status: 'fail',
|
| 182 |
+
message: 'Slide ID is required',
|
| 183 |
+
});
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
const { cloudinary } = require('../config/cloudinary');
|
| 187 |
+
const folderPath = `samoulla/hero-images/${slideId}`;
|
| 188 |
+
|
| 189 |
+
// Delete all resources in the folder (images and videos)
|
| 190 |
+
await cloudinary.api.delete_resources_by_prefix(`${folderPath}/`, {
|
| 191 |
+
resource_type: 'image',
|
| 192 |
+
});
|
| 193 |
+
await cloudinary.api.delete_resources_by_prefix(`${folderPath}/`, {
|
| 194 |
+
resource_type: 'video',
|
| 195 |
+
});
|
| 196 |
+
|
| 197 |
+
// Delete the empty folder
|
| 198 |
+
await cloudinary.api.delete_folder(folderPath);
|
| 199 |
+
|
| 200 |
+
res.status(200).json({
|
| 201 |
+
status: 'success',
|
| 202 |
+
message: 'Hero slide folder deleted successfully',
|
| 203 |
+
});
|
| 204 |
+
} catch (error) {
|
| 205 |
+
res.status(500).json({
|
| 206 |
+
status: 'error',
|
| 207 |
+
message: error.message,
|
| 208 |
+
});
|
| 209 |
+
}
|
| 210 |
+
};
|
controllers/newsletterController.js
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const Newsletter = require('../models/newsletterModel');
|
| 2 |
+
const {
|
| 3 |
+
sendNewsletterSubscriptionEmail,
|
| 4 |
+
sendNewsletterUnsubscribeEmail,
|
| 5 |
+
} = require('../utils/emailService');
|
| 6 |
+
|
| 7 |
+
/**
|
| 8 |
+
* Subscribe to newsletter
|
| 9 |
+
* @route POST /api/v1/newsletter/subscribe
|
| 10 |
+
* @access Public
|
| 11 |
+
*/
|
| 12 |
+
exports.subscribe = async (req, res) => {
|
| 13 |
+
try {
|
| 14 |
+
const { email } = req.body;
|
| 15 |
+
|
| 16 |
+
if (!email) {
|
| 17 |
+
return res.status(400).json({
|
| 18 |
+
status: 'fail',
|
| 19 |
+
message: 'البريد الإلكتروني مطلوب',
|
| 20 |
+
});
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
// Check if email already exists
|
| 24 |
+
const existingSubscriber = await Newsletter.findOne({ email });
|
| 25 |
+
|
| 26 |
+
if (existingSubscriber) {
|
| 27 |
+
if (existingSubscriber.isActive) {
|
| 28 |
+
return res.status(400).json({
|
| 29 |
+
status: 'fail',
|
| 30 |
+
message: 'هذا البريد الإلكتروني مشترك بالفعل',
|
| 31 |
+
});
|
| 32 |
+
} else {
|
| 33 |
+
// Reactivate subscription
|
| 34 |
+
existingSubscriber.isActive = true;
|
| 35 |
+
existingSubscriber.subscribedAt = Date.now();
|
| 36 |
+
await existingSubscriber.save();
|
| 37 |
+
|
| 38 |
+
// Send confirmation email
|
| 39 |
+
sendNewsletterSubscriptionEmail(email).catch((err) =>
|
| 40 |
+
console.error(
|
| 41 |
+
'Failed to send newsletter subscription email:',
|
| 42 |
+
err.message,
|
| 43 |
+
),
|
| 44 |
+
);
|
| 45 |
+
|
| 46 |
+
return res.status(200).json({
|
| 47 |
+
status: 'success',
|
| 48 |
+
message: 'تم تفعيل اشتراكك بنجاح',
|
| 49 |
+
});
|
| 50 |
+
}
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
// Create new subscriber
|
| 54 |
+
const subscriber = await Newsletter.create({ email });
|
| 55 |
+
|
| 56 |
+
// Send confirmation email (don't wait for it)
|
| 57 |
+
sendNewsletterSubscriptionEmail(email).catch((err) =>
|
| 58 |
+
console.error(
|
| 59 |
+
'Failed to send newsletter subscription email:',
|
| 60 |
+
err.message,
|
| 61 |
+
),
|
| 62 |
+
);
|
| 63 |
+
|
| 64 |
+
res.status(201).json({
|
| 65 |
+
status: 'success',
|
| 66 |
+
message: 'تم الاشتراك بنجاح! سنرسل لك أحدث العروض قريباً',
|
| 67 |
+
data: {
|
| 68 |
+
subscriber: {
|
| 69 |
+
email: subscriber.email,
|
| 70 |
+
subscribedAt: subscriber.subscribedAt,
|
| 71 |
+
},
|
| 72 |
+
},
|
| 73 |
+
});
|
| 74 |
+
} catch (err) {
|
| 75 |
+
res.status(500).json({
|
| 76 |
+
status: 'error',
|
| 77 |
+
message: 'حدث خطأ أثناء الاشتراك',
|
| 78 |
+
error: err.message,
|
| 79 |
+
});
|
| 80 |
+
}
|
| 81 |
+
};
|
| 82 |
+
|
| 83 |
+
/**
|
| 84 |
+
* Unsubscribe from newsletter
|
| 85 |
+
* @route POST /api/v1/newsletter/unsubscribe
|
| 86 |
+
* @access Public
|
| 87 |
+
*/
|
| 88 |
+
exports.unsubscribe = async (req, res) => {
|
| 89 |
+
try {
|
| 90 |
+
const { email } = req.body;
|
| 91 |
+
|
| 92 |
+
if (!email) {
|
| 93 |
+
return res.status(400).json({
|
| 94 |
+
status: 'fail',
|
| 95 |
+
message: 'البريد الإلكتروني مطلوب',
|
| 96 |
+
});
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
const subscriber = await Newsletter.findOne({ email });
|
| 100 |
+
|
| 101 |
+
if (!subscriber) {
|
| 102 |
+
return res.status(404).json({
|
| 103 |
+
status: 'fail',
|
| 104 |
+
message: 'البريد الإلكتروني غير مشترك',
|
| 105 |
+
});
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
subscriber.isActive = false;
|
| 109 |
+
await subscriber.save();
|
| 110 |
+
|
| 111 |
+
// Send unsubscribe confirmation email (don't wait for it)
|
| 112 |
+
sendNewsletterUnsubscribeEmail(email).catch((err) =>
|
| 113 |
+
console.error(
|
| 114 |
+
'Failed to send newsletter unsubscribe email:',
|
| 115 |
+
err.message,
|
| 116 |
+
),
|
| 117 |
+
);
|
| 118 |
+
|
| 119 |
+
res.status(200).json({
|
| 120 |
+
status: 'success',
|
| 121 |
+
message: 'تم إلغاء الاشتراك بنجاح',
|
| 122 |
+
});
|
| 123 |
+
} catch (err) {
|
| 124 |
+
res.status(500).json({
|
| 125 |
+
status: 'error',
|
| 126 |
+
message: 'حدث خطأ أثناء إلغاء الاشتراك',
|
| 127 |
+
error: err.message,
|
| 128 |
+
});
|
| 129 |
+
}
|
| 130 |
+
};
|
| 131 |
+
|
| 132 |
+
/**
|
| 133 |
+
* Get all active subscribers (Admin only)
|
| 134 |
+
* @route GET /api/v1/newsletter/subscribers
|
| 135 |
+
* @access Private/Admin
|
| 136 |
+
*/
|
| 137 |
+
exports.getSubscribers = async (req, res) => {
|
| 138 |
+
try {
|
| 139 |
+
const subscribers = await Newsletter.find({ isActive: true })
|
| 140 |
+
.select('email subscribedAt')
|
| 141 |
+
.lean();
|
| 142 |
+
|
| 143 |
+
res.status(200).json({
|
| 144 |
+
status: 'success',
|
| 145 |
+
results: subscribers.length,
|
| 146 |
+
data: {
|
| 147 |
+
subscribers,
|
| 148 |
+
},
|
| 149 |
+
});
|
| 150 |
+
} catch (err) {
|
| 151 |
+
res.status(500).json({
|
| 152 |
+
status: 'error',
|
| 153 |
+
message: 'حدث خطأ أثناء جلب المشتركين',
|
| 154 |
+
error: err.message,
|
| 155 |
+
});
|
| 156 |
+
}
|
| 157 |
+
};
|
controllers/notificationController.js
ADDED
|
@@ -0,0 +1,825 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const Notification = require('../models/notificationModel');
|
| 2 |
+
const Newsletter = require('../models/newsletterModel');
|
| 3 |
+
const { getIO } = require('../utils/socket');
|
| 4 |
+
|
| 5 |
+
// Helper to emit and get unread count
|
| 6 |
+
const getAndEmitUnreadCount = async (userId) => {
|
| 7 |
+
try {
|
| 8 |
+
const unreadCount = await Notification.countDocuments({
|
| 9 |
+
user: userId,
|
| 10 |
+
isRead: false,
|
| 11 |
+
});
|
| 12 |
+
getIO().to(userId.toString()).emit('unreadCountUpdate', unreadCount);
|
| 13 |
+
return unreadCount;
|
| 14 |
+
} catch (err) {
|
| 15 |
+
console.error('Error emitting unread count:', err);
|
| 16 |
+
return 0;
|
| 17 |
+
}
|
| 18 |
+
};
|
| 19 |
+
|
| 20 |
+
// Get all notifications for the authenticated user
|
| 21 |
+
exports.getMyNotifications = async (req, res) => {
|
| 22 |
+
try {
|
| 23 |
+
if (!req.user) {
|
| 24 |
+
return res.status(401).json({
|
| 25 |
+
status: 'fail',
|
| 26 |
+
message: 'You must be logged in to view notifications',
|
| 27 |
+
});
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
const page = parseInt(req.query.page, 10) || 1;
|
| 31 |
+
const limit = parseInt(req.query.limit, 10) || 20;
|
| 32 |
+
const skip = (page - 1) * limit;
|
| 33 |
+
|
| 34 |
+
// Filter options
|
| 35 |
+
const filter = { user: req.user._id };
|
| 36 |
+
|
| 37 |
+
if (req.query.isRead !== undefined) {
|
| 38 |
+
filter.isRead = req.query.isRead === 'true';
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
if (req.query.type) {
|
| 42 |
+
filter.type = req.query.type;
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
const notifications = await Notification.find(filter)
|
| 46 |
+
.sort({ createdAt: -1 })
|
| 47 |
+
.skip(skip)
|
| 48 |
+
.limit(limit)
|
| 49 |
+
.populate('relatedId');
|
| 50 |
+
|
| 51 |
+
const total = await Notification.countDocuments(filter);
|
| 52 |
+
const unreadCount = await Notification.countDocuments({
|
| 53 |
+
user: req.user._id,
|
| 54 |
+
isRead: false,
|
| 55 |
+
});
|
| 56 |
+
|
| 57 |
+
res.status(200).json({
|
| 58 |
+
status: 'success',
|
| 59 |
+
results: notifications.length,
|
| 60 |
+
data: {
|
| 61 |
+
notifications,
|
| 62 |
+
pagination: {
|
| 63 |
+
page,
|
| 64 |
+
limit,
|
| 65 |
+
total,
|
| 66 |
+
pages: Math.ceil(total / limit),
|
| 67 |
+
},
|
| 68 |
+
unreadCount,
|
| 69 |
+
},
|
| 70 |
+
});
|
| 71 |
+
} catch (err) {
|
| 72 |
+
res.status(500).json({
|
| 73 |
+
status: 'error',
|
| 74 |
+
message: err.message,
|
| 75 |
+
});
|
| 76 |
+
}
|
| 77 |
+
};
|
| 78 |
+
|
| 79 |
+
// Get unread notification count
|
| 80 |
+
exports.getUnreadCount = async (req, res) => {
|
| 81 |
+
try {
|
| 82 |
+
if (!req.user) {
|
| 83 |
+
return res.status(401).json({
|
| 84 |
+
status: 'fail',
|
| 85 |
+
message: 'You must be logged in',
|
| 86 |
+
});
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
const count = await Notification.countDocuments({
|
| 90 |
+
user: req.user._id,
|
| 91 |
+
isRead: false,
|
| 92 |
+
});
|
| 93 |
+
|
| 94 |
+
res.status(200).json({
|
| 95 |
+
status: 'success',
|
| 96 |
+
data: { unreadCount: count },
|
| 97 |
+
});
|
| 98 |
+
} catch (err) {
|
| 99 |
+
res.status(500).json({
|
| 100 |
+
status: 'error',
|
| 101 |
+
message: err.message,
|
| 102 |
+
});
|
| 103 |
+
}
|
| 104 |
+
};
|
| 105 |
+
|
| 106 |
+
// Mark a notification as read
|
| 107 |
+
exports.markAsRead = async (req, res) => {
|
| 108 |
+
try {
|
| 109 |
+
if (!req.user) {
|
| 110 |
+
return res.status(401).json({
|
| 111 |
+
status: 'fail',
|
| 112 |
+
message: 'You must be logged in',
|
| 113 |
+
});
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
const notification = await Notification.findOneAndUpdate(
|
| 117 |
+
{ _id: req.params.id, user: req.user._id },
|
| 118 |
+
{ isRead: true, readAt: new Date() },
|
| 119 |
+
{ new: true },
|
| 120 |
+
);
|
| 121 |
+
|
| 122 |
+
if (!notification) {
|
| 123 |
+
return res.status(404).json({
|
| 124 |
+
status: 'fail',
|
| 125 |
+
message: 'Notification not found',
|
| 126 |
+
});
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
const unreadCount = await getAndEmitUnreadCount(req.user._id);
|
| 130 |
+
|
| 131 |
+
// Sync admin dashboard: Notify admins that this notification was read
|
| 132 |
+
getIO().emit('notificationReadAdmin', {
|
| 133 |
+
notificationId: notification._id,
|
| 134 |
+
userId: req.user._id,
|
| 135 |
+
isRead: true,
|
| 136 |
+
});
|
| 137 |
+
|
| 138 |
+
res.status(200).json({
|
| 139 |
+
status: 'success',
|
| 140 |
+
data: { notification, unreadCount },
|
| 141 |
+
});
|
| 142 |
+
} catch (err) {
|
| 143 |
+
res.status(500).json({
|
| 144 |
+
status: 'error',
|
| 145 |
+
message: err.message,
|
| 146 |
+
});
|
| 147 |
+
}
|
| 148 |
+
};
|
| 149 |
+
|
| 150 |
+
// Mark all notifications as read
|
| 151 |
+
exports.markAllAsRead = async (req, res) => {
|
| 152 |
+
try {
|
| 153 |
+
if (!req.user) {
|
| 154 |
+
return res.status(401).json({
|
| 155 |
+
status: 'fail',
|
| 156 |
+
message: 'You must be logged in',
|
| 157 |
+
});
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
await Notification.updateMany(
|
| 161 |
+
{ user: req.user._id, isRead: false },
|
| 162 |
+
{ isRead: true, readAt: new Date() },
|
| 163 |
+
);
|
| 164 |
+
|
| 165 |
+
await getAndEmitUnreadCount(req.user._id);
|
| 166 |
+
|
| 167 |
+
// Sync admin dashboard: Notify admins that ALL user notifications were read
|
| 168 |
+
getIO().emit('notificationAllReadAdmin', {
|
| 169 |
+
userId: req.user._id,
|
| 170 |
+
});
|
| 171 |
+
|
| 172 |
+
res.status(200).json({
|
| 173 |
+
status: 'success',
|
| 174 |
+
message: 'All notifications marked as read',
|
| 175 |
+
data: { unreadCount: 0 },
|
| 176 |
+
});
|
| 177 |
+
} catch (err) {
|
| 178 |
+
res.status(500).json({
|
| 179 |
+
status: 'error',
|
| 180 |
+
message: err.message,
|
| 181 |
+
});
|
| 182 |
+
}
|
| 183 |
+
};
|
| 184 |
+
|
| 185 |
+
// Delete a notification
|
| 186 |
+
exports.deleteNotification = async (req, res) => {
|
| 187 |
+
try {
|
| 188 |
+
if (!req.user) {
|
| 189 |
+
return res.status(401).json({
|
| 190 |
+
status: 'fail',
|
| 191 |
+
message: 'You must be logged in',
|
| 192 |
+
});
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
const notification = await Notification.findOneAndDelete({
|
| 196 |
+
_id: req.params.id,
|
| 197 |
+
user: req.user._id,
|
| 198 |
+
});
|
| 199 |
+
|
| 200 |
+
if (!notification) {
|
| 201 |
+
return res.status(404).json({
|
| 202 |
+
status: 'fail',
|
| 203 |
+
message: 'Notification not found',
|
| 204 |
+
});
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
const unreadCount = await getAndEmitUnreadCount(req.user._id);
|
| 208 |
+
|
| 209 |
+
// Sync admin dashboard: Notify admins that this notification was deleted
|
| 210 |
+
getIO().emit('notificationDeletedAdmin', notification._id);
|
| 211 |
+
|
| 212 |
+
res.status(200).json({
|
| 213 |
+
status: 'success',
|
| 214 |
+
data: { unreadCount },
|
| 215 |
+
});
|
| 216 |
+
} catch (err) {
|
| 217 |
+
res.status(500).json({
|
| 218 |
+
status: 'error',
|
| 219 |
+
message: err.message,
|
| 220 |
+
});
|
| 221 |
+
}
|
| 222 |
+
};
|
| 223 |
+
|
| 224 |
+
// Delete all read notifications
|
| 225 |
+
exports.deleteAllRead = async (req, res) => {
|
| 226 |
+
try {
|
| 227 |
+
if (!req.user) {
|
| 228 |
+
return res.status(401).json({
|
| 229 |
+
status: 'fail',
|
| 230 |
+
message: 'You must be logged in',
|
| 231 |
+
});
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
await Notification.deleteMany({
|
| 235 |
+
user: req.user._id,
|
| 236 |
+
isRead: true,
|
| 237 |
+
});
|
| 238 |
+
|
| 239 |
+
// Sync admin dashboard: Notify admins that ALL user read notifications were deleted
|
| 240 |
+
getIO().emit('notificationAllDeletedReadAdmin', {
|
| 241 |
+
userId: req.user._id,
|
| 242 |
+
});
|
| 243 |
+
|
| 244 |
+
res.status(200).json({
|
| 245 |
+
status: 'success',
|
| 246 |
+
message: 'All read notifications deleted',
|
| 247 |
+
});
|
| 248 |
+
} catch (err) {
|
| 249 |
+
res.status(500).json({
|
| 250 |
+
status: 'error',
|
| 251 |
+
message: err.message,
|
| 252 |
+
});
|
| 253 |
+
}
|
| 254 |
+
};
|
| 255 |
+
|
| 256 |
+
// Admin: Get all notifications
|
| 257 |
+
exports.getAllNotifications = async (req, res) => {
|
| 258 |
+
try {
|
| 259 |
+
const page = parseInt(req.query.page, 10) || 1;
|
| 260 |
+
const limit = parseInt(req.query.limit, 10) || 50;
|
| 261 |
+
const skip = (page - 1) * limit;
|
| 262 |
+
|
| 263 |
+
const notifications = await Notification.find()
|
| 264 |
+
.populate('user', 'name email')
|
| 265 |
+
.sort({ createdAt: -1 })
|
| 266 |
+
.skip(skip)
|
| 267 |
+
.limit(limit);
|
| 268 |
+
|
| 269 |
+
const total = await Notification.countDocuments();
|
| 270 |
+
|
| 271 |
+
res.status(200).json({
|
| 272 |
+
status: 'success',
|
| 273 |
+
results: notifications.length,
|
| 274 |
+
data: {
|
| 275 |
+
notifications,
|
| 276 |
+
pagination: {
|
| 277 |
+
page,
|
| 278 |
+
limit,
|
| 279 |
+
total,
|
| 280 |
+
pages: Math.ceil(total / limit),
|
| 281 |
+
},
|
| 282 |
+
},
|
| 283 |
+
});
|
| 284 |
+
} catch (err) {
|
| 285 |
+
res.status(500).json({
|
| 286 |
+
status: 'error',
|
| 287 |
+
message: err.message,
|
| 288 |
+
});
|
| 289 |
+
}
|
| 290 |
+
};
|
| 291 |
+
|
| 292 |
+
// Admin: Create notification for specific user(s)
|
| 293 |
+
exports.createNotification = async (req, res) => {
|
| 294 |
+
try {
|
| 295 |
+
const {
|
| 296 |
+
userId,
|
| 297 |
+
title,
|
| 298 |
+
message,
|
| 299 |
+
type,
|
| 300 |
+
priority,
|
| 301 |
+
metadata,
|
| 302 |
+
relatedId,
|
| 303 |
+
relatedModel,
|
| 304 |
+
} = req.body;
|
| 305 |
+
|
| 306 |
+
if (!userId || !title || !message) {
|
| 307 |
+
return res.status(400).json({
|
| 308 |
+
status: 'fail',
|
| 309 |
+
message: 'Please provide userId, title, and message',
|
| 310 |
+
});
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
const {
|
| 314 |
+
createNotification: createNotifService,
|
| 315 |
+
} = require('../utils/notificationService');
|
| 316 |
+
const notification = await createNotifService({
|
| 317 |
+
userId,
|
| 318 |
+
title,
|
| 319 |
+
message,
|
| 320 |
+
type: type || 'general',
|
| 321 |
+
priority: priority || 'medium',
|
| 322 |
+
metadata,
|
| 323 |
+
relatedId: relatedId || undefined,
|
| 324 |
+
relatedModel: relatedModel || undefined,
|
| 325 |
+
});
|
| 326 |
+
|
| 327 |
+
if (!notification) {
|
| 328 |
+
return res.status(200).json({
|
| 329 |
+
status: 'success',
|
| 330 |
+
message: 'Notification skipped due to user preferences or error',
|
| 331 |
+
data: null,
|
| 332 |
+
});
|
| 333 |
+
}
|
| 334 |
+
|
| 335 |
+
res.status(201).json({
|
| 336 |
+
status: 'success',
|
| 337 |
+
data: { notification },
|
| 338 |
+
});
|
| 339 |
+
} catch (err) {
|
| 340 |
+
res.status(500).json({
|
| 341 |
+
status: 'error',
|
| 342 |
+
message: err.message,
|
| 343 |
+
});
|
| 344 |
+
}
|
| 345 |
+
};
|
| 346 |
+
|
| 347 |
+
// Admin: Broadcast notification to all users
|
| 348 |
+
exports.broadcastNotification = async (req, res) => {
|
| 349 |
+
try {
|
| 350 |
+
const {
|
| 351 |
+
title,
|
| 352 |
+
message,
|
| 353 |
+
type,
|
| 354 |
+
priority,
|
| 355 |
+
metadata,
|
| 356 |
+
userRole,
|
| 357 |
+
relatedId,
|
| 358 |
+
relatedModel,
|
| 359 |
+
} = req.body;
|
| 360 |
+
|
| 361 |
+
if (!title || !message) {
|
| 362 |
+
return res.status(400).json({
|
| 363 |
+
status: 'fail',
|
| 364 |
+
message: 'Please provide title and message',
|
| 365 |
+
});
|
| 366 |
+
}
|
| 367 |
+
|
| 368 |
+
const User = require('../models/userModel');
|
| 369 |
+
|
| 370 |
+
let users;
|
| 371 |
+
if (userRole === 'subscriber') {
|
| 372 |
+
// Find all active newsletter emails
|
| 373 |
+
const subscribers = await Newsletter.find({ isActive: true }).select(
|
| 374 |
+
'email',
|
| 375 |
+
);
|
| 376 |
+
const emails = subscribers.map((s) => s.email);
|
| 377 |
+
|
| 378 |
+
// Find users whose email is in the subscribers list
|
| 379 |
+
users = await User.find({ email: { $in: emails } }).select('_id');
|
| 380 |
+
} else {
|
| 381 |
+
// Filter users by role if specified
|
| 382 |
+
const filter = userRole && userRole !== 'all' ? { role: userRole } : {};
|
| 383 |
+
users = await User.find(filter).select('_id');
|
| 384 |
+
}
|
| 385 |
+
|
| 386 |
+
const recipients = users.map((u) => u._id);
|
| 387 |
+
const { createBulkNotifications } = require('../utils/notificationService');
|
| 388 |
+
|
| 389 |
+
const results = await createBulkNotifications(recipients, {
|
| 390 |
+
title,
|
| 391 |
+
message,
|
| 392 |
+
type: type || 'general',
|
| 393 |
+
priority: priority || 'medium',
|
| 394 |
+
metadata,
|
| 395 |
+
relatedId: relatedId || undefined,
|
| 396 |
+
relatedModel: relatedModel || undefined,
|
| 397 |
+
});
|
| 398 |
+
|
| 399 |
+
res.status(201).json({
|
| 400 |
+
status: 'success',
|
| 401 |
+
message: `Notification sent to ${results.length} recipients who opted in`,
|
| 402 |
+
data: { count: results.length },
|
| 403 |
+
});
|
| 404 |
+
} catch (err) {
|
| 405 |
+
res.status(500).json({
|
| 406 |
+
status: 'error',
|
| 407 |
+
message: err.message,
|
| 408 |
+
});
|
| 409 |
+
}
|
| 410 |
+
};
|
| 411 |
+
|
| 412 |
+
// Admin: Delete any notification (no user ownership check)
|
| 413 |
+
exports.adminDeleteNotification = async (req, res) => {
|
| 414 |
+
try {
|
| 415 |
+
const notification = await Notification.findByIdAndDelete(req.params.id);
|
| 416 |
+
|
| 417 |
+
if (!notification) {
|
| 418 |
+
return res.status(404).json({
|
| 419 |
+
status: 'fail',
|
| 420 |
+
message: 'Notification not found',
|
| 421 |
+
});
|
| 422 |
+
}
|
| 423 |
+
|
| 424 |
+
const io = getIO();
|
| 425 |
+
// Notify the specific user that their notification was deleted
|
| 426 |
+
io.to(notification.user.toString()).emit(
|
| 427 |
+
'notificationDeleted',
|
| 428 |
+
notification._id,
|
| 429 |
+
);
|
| 430 |
+
|
| 431 |
+
// Update the user's unread count after deletion
|
| 432 |
+
await getAndEmitUnreadCount(notification.user);
|
| 433 |
+
|
| 434 |
+
// Sync other admins
|
| 435 |
+
io.emit('notificationDeletedAdmin', notification._id);
|
| 436 |
+
|
| 437 |
+
res.status(200).json({
|
| 438 |
+
status: 'success',
|
| 439 |
+
message: 'Notification deleted successfully',
|
| 440 |
+
});
|
| 441 |
+
} catch (err) {
|
| 442 |
+
res.status(500).json({
|
| 443 |
+
status: 'error',
|
| 444 |
+
message: err.message,
|
| 445 |
+
});
|
| 446 |
+
}
|
| 447 |
+
};
|
| 448 |
+
|
| 449 |
+
// Get user's notification preferences
|
| 450 |
+
exports.getMyPreferences = async (req, res) => {
|
| 451 |
+
try {
|
| 452 |
+
if (!req.user) {
|
| 453 |
+
return res.status(401).json({
|
| 454 |
+
status: 'fail',
|
| 455 |
+
message: 'You must be logged in',
|
| 456 |
+
});
|
| 457 |
+
}
|
| 458 |
+
|
| 459 |
+
res.status(200).json({
|
| 460 |
+
status: 'success',
|
| 461 |
+
data: {
|
| 462 |
+
preferences: req.user.notificationPreferences,
|
| 463 |
+
},
|
| 464 |
+
});
|
| 465 |
+
} catch (err) {
|
| 466 |
+
res.status(500).json({
|
| 467 |
+
status: 'error',
|
| 468 |
+
message: err.message,
|
| 469 |
+
});
|
| 470 |
+
}
|
| 471 |
+
};
|
| 472 |
+
|
| 473 |
+
// Update user's notification preferences
|
| 474 |
+
exports.updateMyPreferences = async (req, res) => {
|
| 475 |
+
try {
|
| 476 |
+
if (!req.user) {
|
| 477 |
+
return res.status(401).json({
|
| 478 |
+
status: 'fail',
|
| 479 |
+
message: 'You must be logged in',
|
| 480 |
+
});
|
| 481 |
+
}
|
| 482 |
+
|
| 483 |
+
const { preferences } = req.body;
|
| 484 |
+
|
| 485 |
+
if (!preferences) {
|
| 486 |
+
return res.status(400).json({
|
| 487 |
+
status: 'fail',
|
| 488 |
+
message: 'Please provide preferences to update',
|
| 489 |
+
});
|
| 490 |
+
}
|
| 491 |
+
|
| 492 |
+
// Update user document
|
| 493 |
+
const User = require('../models/userModel');
|
| 494 |
+
const existingUser = await User.findById(req.user._id);
|
| 495 |
+
|
| 496 |
+
// Guard: Force-enable notifications only for categories the user has permission for.
|
| 497 |
+
// Admins always receive everything. Employees only receive what matches their permissions.
|
| 498 |
+
if (req.user.role === 'admin') {
|
| 499 |
+
if (preferences.orderUpdates) {
|
| 500 |
+
preferences.orderUpdates.app = true;
|
| 501 |
+
preferences.orderUpdates.email = true;
|
| 502 |
+
}
|
| 503 |
+
if (preferences.productUpdates) {
|
| 504 |
+
preferences.productUpdates.app = true;
|
| 505 |
+
preferences.productUpdates.email = true;
|
| 506 |
+
}
|
| 507 |
+
delete preferences.vendorOrderVisibility;
|
| 508 |
+
} else if (req.user.role === 'employee') {
|
| 509 |
+
const empPerms = req.user.permissions || [];
|
| 510 |
+
// Only force-enable orderUpdates if the employee has manage_orders permission
|
| 511 |
+
if (preferences.orderUpdates && empPerms.includes('manage_orders')) {
|
| 512 |
+
preferences.orderUpdates.app = true;
|
| 513 |
+
preferences.orderUpdates.email = true;
|
| 514 |
+
}
|
| 515 |
+
// Only force-enable productUpdates if the employee has manage_products permission
|
| 516 |
+
if (preferences.productUpdates && empPerms.includes('manage_products')) {
|
| 517 |
+
preferences.productUpdates.app = true;
|
| 518 |
+
preferences.productUpdates.email = true;
|
| 519 |
+
}
|
| 520 |
+
delete preferences.vendorOrderVisibility;
|
| 521 |
+
}
|
| 522 |
+
|
| 523 |
+
// Logic to handle disabledAt and blackoutPeriods for vendorOrderVisibility
|
| 524 |
+
if (preferences.vendorOrderVisibility) {
|
| 525 |
+
const oldPref =
|
| 526 |
+
(existingUser.notificationPreferences &&
|
| 527 |
+
existingUser.notificationPreferences.vendorOrderVisibility) ||
|
| 528 |
+
{};
|
| 529 |
+
const wasEnabled = oldPref.app !== false;
|
| 530 |
+
const isEnabled = preferences.vendorOrderVisibility.app !== false;
|
| 531 |
+
|
| 532 |
+
const periods = oldPref.blackoutPeriods || [];
|
| 533 |
+
|
| 534 |
+
if (wasEnabled && !isEnabled) {
|
| 535 |
+
// Just turned off: set disabledAt (start of new blackout)
|
| 536 |
+
preferences.vendorOrderVisibility.disabledAt = new Date();
|
| 537 |
+
preferences.vendorOrderVisibility.blackoutPeriods = periods;
|
| 538 |
+
} else if (!wasEnabled && isEnabled) {
|
| 539 |
+
// Just turned on: close the current blackout period and clear disabledAt
|
| 540 |
+
if (oldPref.disabledAt) {
|
| 541 |
+
periods.push({
|
| 542 |
+
start: oldPref.disabledAt,
|
| 543 |
+
end: new Date(),
|
| 544 |
+
});
|
| 545 |
+
}
|
| 546 |
+
preferences.vendorOrderVisibility.blackoutPeriods = periods;
|
| 547 |
+
preferences.vendorOrderVisibility.disabledAt = null;
|
| 548 |
+
} else if (!wasEnabled && !isEnabled) {
|
| 549 |
+
// Stayed off: carry over state
|
| 550 |
+
preferences.vendorOrderVisibility.disabledAt = oldPref.disabledAt;
|
| 551 |
+
preferences.vendorOrderVisibility.blackoutPeriods = periods;
|
| 552 |
+
} else {
|
| 553 |
+
// Stayed on: carry over state
|
| 554 |
+
preferences.vendorOrderVisibility.disabledAt = null;
|
| 555 |
+
preferences.vendorOrderVisibility.blackoutPeriods = periods;
|
| 556 |
+
}
|
| 557 |
+
}
|
| 558 |
+
|
| 559 |
+
const user = await User.findByIdAndUpdate(
|
| 560 |
+
req.user._id,
|
| 561 |
+
{ notificationPreferences: preferences },
|
| 562 |
+
{ new: true, runValidators: true },
|
| 563 |
+
);
|
| 564 |
+
|
| 565 |
+
res.status(200).json({
|
| 566 |
+
status: 'success',
|
| 567 |
+
data: {
|
| 568 |
+
preferences: user.notificationPreferences,
|
| 569 |
+
},
|
| 570 |
+
});
|
| 571 |
+
} catch (err) {
|
| 572 |
+
res.status(500).json({
|
| 573 |
+
status: 'error',
|
| 574 |
+
message: err.message,
|
| 575 |
+
});
|
| 576 |
+
}
|
| 577 |
+
};
|
| 578 |
+
|
| 579 |
+
// Admin: Get current vendor preference state (reads first active vendor as representative)
|
| 580 |
+
exports.getVendorGlobalPreferences = async (req, res) => {
|
| 581 |
+
try {
|
| 582 |
+
const User = require('../models/userModel');
|
| 583 |
+
const vendor = await User.findOne({ role: 'vendor', isActive: true })
|
| 584 |
+
.select('notificationPreferences')
|
| 585 |
+
.lean();
|
| 586 |
+
|
| 587 |
+
const defaults = {
|
| 588 |
+
orderUpdates: { app: true, email: true },
|
| 589 |
+
productUpdates: { app: true, email: true },
|
| 590 |
+
vendorOrderVisibility: { app: true, email: true },
|
| 591 |
+
};
|
| 592 |
+
|
| 593 |
+
if (!vendor || !vendor.notificationPreferences) {
|
| 594 |
+
return res
|
| 595 |
+
.status(200)
|
| 596 |
+
.json({ status: 'success', data: { preferences: defaults } });
|
| 597 |
+
}
|
| 598 |
+
|
| 599 |
+
const p = vendor.notificationPreferences;
|
| 600 |
+
return res.status(200).json({
|
| 601 |
+
status: 'success',
|
| 602 |
+
data: {
|
| 603 |
+
preferences: {
|
| 604 |
+
orderUpdates: {
|
| 605 |
+
app: p.orderUpdates?.app !== false,
|
| 606 |
+
email: p.orderUpdates?.email !== false,
|
| 607 |
+
},
|
| 608 |
+
productUpdates: {
|
| 609 |
+
app: p.productUpdates?.app !== false,
|
| 610 |
+
email: p.productUpdates?.email !== false,
|
| 611 |
+
},
|
| 612 |
+
vendorOrderVisibility: {
|
| 613 |
+
app: p.vendorOrderVisibility?.app !== false,
|
| 614 |
+
email: p.vendorOrderVisibility?.email !== false,
|
| 615 |
+
},
|
| 616 |
+
},
|
| 617 |
+
},
|
| 618 |
+
});
|
| 619 |
+
} catch (err) {
|
| 620 |
+
res.status(500).json({ status: 'error', message: err.message });
|
| 621 |
+
}
|
| 622 |
+
};
|
| 623 |
+
|
| 624 |
+
// Admin: Bulk-update preferences for ALL active vendor users
|
| 625 |
+
// This lets admins globally control whether vendors receive notifications / see orders
|
| 626 |
+
exports.updateAllVendorPreferences = async (req, res) => {
|
| 627 |
+
try {
|
| 628 |
+
if (!req.user) {
|
| 629 |
+
return res
|
| 630 |
+
.status(401)
|
| 631 |
+
.json({ status: 'fail', message: 'You must be logged in' });
|
| 632 |
+
}
|
| 633 |
+
|
| 634 |
+
const { preferences } = req.body;
|
| 635 |
+
if (!preferences) {
|
| 636 |
+
return res
|
| 637 |
+
.status(400)
|
| 638 |
+
.json({ status: 'fail', message: 'Please provide preferences' });
|
| 639 |
+
}
|
| 640 |
+
|
| 641 |
+
const User = require('../models/userModel');
|
| 642 |
+
const now = new Date();
|
| 643 |
+
|
| 644 |
+
// Find all active vendor users
|
| 645 |
+
const vendors = await User.find({ role: 'vendor', isActive: true }).select(
|
| 646 |
+
'_id notificationPreferences',
|
| 647 |
+
);
|
| 648 |
+
|
| 649 |
+
if (vendors.length === 0) {
|
| 650 |
+
return res.status(200).json({
|
| 651 |
+
status: 'success',
|
| 652 |
+
message: 'No active vendors found',
|
| 653 |
+
data: { updatedCount: 0 },
|
| 654 |
+
});
|
| 655 |
+
}
|
| 656 |
+
|
| 657 |
+
const bulkOps = vendors.map((vendor) => {
|
| 658 |
+
const existingPrefs = vendor.notificationPreferences || {};
|
| 659 |
+
const updatedPrefs = { ...existingPrefs };
|
| 660 |
+
|
| 661 |
+
// Apply orderUpdates preference — app and email are always kept in sync
|
| 662 |
+
if (preferences.orderUpdates !== undefined) {
|
| 663 |
+
const val =
|
| 664 |
+
typeof preferences.orderUpdates.app === 'boolean'
|
| 665 |
+
? preferences.orderUpdates.app
|
| 666 |
+
: true;
|
| 667 |
+
updatedPrefs.orderUpdates = {
|
| 668 |
+
...(existingPrefs.orderUpdates || {}),
|
| 669 |
+
app: val,
|
| 670 |
+
email: val,
|
| 671 |
+
};
|
| 672 |
+
}
|
| 673 |
+
|
| 674 |
+
// Apply productUpdates preference — app and email are always kept in sync
|
| 675 |
+
if (preferences.productUpdates !== undefined) {
|
| 676 |
+
const val =
|
| 677 |
+
typeof preferences.productUpdates.app === 'boolean'
|
| 678 |
+
? preferences.productUpdates.app
|
| 679 |
+
: true;
|
| 680 |
+
updatedPrefs.productUpdates = {
|
| 681 |
+
...(existingPrefs.productUpdates || {}),
|
| 682 |
+
app: val,
|
| 683 |
+
email: val,
|
| 684 |
+
};
|
| 685 |
+
}
|
| 686 |
+
|
| 687 |
+
// Apply vendorOrderVisibility preference with blackout tracking
|
| 688 |
+
if (preferences.vendorOrderVisibility !== undefined) {
|
| 689 |
+
const oldVis = existingPrefs.vendorOrderVisibility || {};
|
| 690 |
+
const wasEnabled = oldVis.app !== false;
|
| 691 |
+
const isEnabled = preferences.vendorOrderVisibility.app !== false;
|
| 692 |
+
const periods = oldVis.blackoutPeriods || [];
|
| 693 |
+
|
| 694 |
+
let disabledAt = oldVis.disabledAt || null;
|
| 695 |
+
|
| 696 |
+
if (wasEnabled && !isEnabled) {
|
| 697 |
+
// Turning OFF: record when it was disabled
|
| 698 |
+
disabledAt = now;
|
| 699 |
+
} else if (!wasEnabled && isEnabled) {
|
| 700 |
+
// Turning ON: close the current blackout period
|
| 701 |
+
if (oldVis.disabledAt) {
|
| 702 |
+
periods.push({ start: oldVis.disabledAt, end: now });
|
| 703 |
+
}
|
| 704 |
+
disabledAt = null;
|
| 705 |
+
} else if (!wasEnabled && !isEnabled) {
|
| 706 |
+
// Stayed off: keep previous disabledAt
|
| 707 |
+
} else {
|
| 708 |
+
// Stayed on
|
| 709 |
+
disabledAt = null;
|
| 710 |
+
}
|
| 711 |
+
|
| 712 |
+
// vendorOrderVisibility: app and email always in sync
|
| 713 |
+
updatedPrefs.vendorOrderVisibility = {
|
| 714 |
+
app: isEnabled,
|
| 715 |
+
email: isEnabled,
|
| 716 |
+
disabledAt,
|
| 717 |
+
blackoutPeriods: periods,
|
| 718 |
+
};
|
| 719 |
+
}
|
| 720 |
+
|
| 721 |
+
return {
|
| 722 |
+
updateOne: {
|
| 723 |
+
filter: { _id: vendor._id },
|
| 724 |
+
update: { $set: { notificationPreferences: updatedPrefs } },
|
| 725 |
+
},
|
| 726 |
+
};
|
| 727 |
+
});
|
| 728 |
+
|
| 729 |
+
const result = await User.bulkWrite(bulkOps);
|
| 730 |
+
|
| 731 |
+
// Emit a socket event so online vendors refresh their preferences
|
| 732 |
+
const { getIO } = require('../utils/socket');
|
| 733 |
+
try {
|
| 734 |
+
const io = getIO();
|
| 735 |
+
vendors.forEach((vendor) => {
|
| 736 |
+
io.to(vendor._id.toString()).emit('preferencesUpdated', {
|
| 737 |
+
preferences: bulkOps.find(
|
| 738 |
+
(op) =>
|
| 739 |
+
op.updateOne.filter._id.toString() === vendor._id.toString(),
|
| 740 |
+
)?.updateOne?.update?.$set?.notificationPreferences,
|
| 741 |
+
});
|
| 742 |
+
});
|
| 743 |
+
} catch (socketErr) {
|
| 744 |
+
// Non-critical — socket might not be available
|
| 745 |
+
console.error(
|
| 746 |
+
'Socket emit error in updateAllVendorPreferences:',
|
| 747 |
+
socketErr,
|
| 748 |
+
);
|
| 749 |
+
}
|
| 750 |
+
|
| 751 |
+
res.status(200).json({
|
| 752 |
+
status: 'success',
|
| 753 |
+
message: `Updated preferences for ${result.modifiedCount} vendor(s)`,
|
| 754 |
+
data: { updatedCount: result.modifiedCount },
|
| 755 |
+
});
|
| 756 |
+
} catch (err) {
|
| 757 |
+
res.status(500).json({ status: 'error', message: err.message });
|
| 758 |
+
}
|
| 759 |
+
};
|
| 760 |
+
|
| 761 |
+
// ─── Stock Subscription Handlers ─────────────────────────────────────────────
|
| 762 |
+
|
| 763 |
+
const StockSubscription = require('../models/stockSubscriptionModel');
|
| 764 |
+
|
| 765 |
+
// POST /notifications/stock-subscribe/:productId
|
| 766 |
+
exports.subscribeToStock = async (req, res) => {
|
| 767 |
+
try {
|
| 768 |
+
const { productId } = req.params;
|
| 769 |
+
const userId = req.user._id;
|
| 770 |
+
|
| 771 |
+
// Upsert: create if not exists, reset notifiedAt so they get notified again on next restock
|
| 772 |
+
await StockSubscription.findOneAndUpdate(
|
| 773 |
+
{ user: userId, product: productId },
|
| 774 |
+
{ user: userId, product: productId, notifiedAt: null },
|
| 775 |
+
{ upsert: true, new: true, setDefaultsOnInsert: true },
|
| 776 |
+
);
|
| 777 |
+
|
| 778 |
+
res.status(200).json({
|
| 779 |
+
status: 'success',
|
| 780 |
+
data: { subscribed: true },
|
| 781 |
+
});
|
| 782 |
+
} catch (err) {
|
| 783 |
+
res.status(500).json({ status: 'error', message: err.message });
|
| 784 |
+
}
|
| 785 |
+
};
|
| 786 |
+
|
| 787 |
+
// DELETE /notifications/stock-subscribe/:productId
|
| 788 |
+
exports.unsubscribeFromStock = async (req, res) => {
|
| 789 |
+
try {
|
| 790 |
+
const { productId } = req.params;
|
| 791 |
+
const userId = req.user._id;
|
| 792 |
+
|
| 793 |
+
await StockSubscription.findOneAndDelete({
|
| 794 |
+
user: userId,
|
| 795 |
+
product: productId,
|
| 796 |
+
});
|
| 797 |
+
|
| 798 |
+
res.status(200).json({
|
| 799 |
+
status: 'success',
|
| 800 |
+
data: { subscribed: false },
|
| 801 |
+
});
|
| 802 |
+
} catch (err) {
|
| 803 |
+
res.status(500).json({ status: 'error', message: err.message });
|
| 804 |
+
}
|
| 805 |
+
};
|
| 806 |
+
|
| 807 |
+
// GET /notifications/stock-subscribe/:productId
|
| 808 |
+
exports.checkStockSubscription = async (req, res) => {
|
| 809 |
+
try {
|
| 810 |
+
const { productId } = req.params;
|
| 811 |
+
const userId = req.user._id;
|
| 812 |
+
|
| 813 |
+
const sub = await StockSubscription.findOne({
|
| 814 |
+
user: userId,
|
| 815 |
+
product: productId,
|
| 816 |
+
});
|
| 817 |
+
|
| 818 |
+
res.status(200).json({
|
| 819 |
+
status: 'success',
|
| 820 |
+
data: { subscribed: !!sub },
|
| 821 |
+
});
|
| 822 |
+
} catch (err) {
|
| 823 |
+
res.status(500).json({ status: 'error', message: err.message });
|
| 824 |
+
}
|
| 825 |
+
};
|
controllers/orderController.js
ADDED
|
@@ -0,0 +1,1317 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const Order = require('../models/orderModel');
|
| 2 |
+
const Product = require('../models/productModel');
|
| 3 |
+
const ShippingPrice = require('../models/shippingPriceModel');
|
| 4 |
+
const Capital = require('../models/capitalModel');
|
| 5 |
+
const Promo = require('../models/promoCodeModel');
|
| 6 |
+
const User = require('../models/userModel');
|
| 7 |
+
const {
|
| 8 |
+
sendOrderConfirmationEmail,
|
| 9 |
+
sendOrderStatusUpdateEmail,
|
| 10 |
+
sendAdminNewOrderEmail,
|
| 11 |
+
} = require('../utils/emailService');
|
| 12 |
+
const { PERMISSIONS } = require('../utils/permissions');
|
| 13 |
+
const {
|
| 14 |
+
notifyOrderCreated,
|
| 15 |
+
notifyOrderProcessing,
|
| 16 |
+
notifyOrderShipped,
|
| 17 |
+
notifyOrderCompleted,
|
| 18 |
+
notifyOrderCancelled,
|
| 19 |
+
notifyVendorNewOrder,
|
| 20 |
+
notifyProductOutOfStock,
|
| 21 |
+
notifyAdminsNewOrder,
|
| 22 |
+
notifyAdminsProductOutOfStock,
|
| 23 |
+
emitOrderUpdate,
|
| 24 |
+
} = require('../utils/notificationService');
|
| 25 |
+
|
| 26 |
+
const { restoreOrderStock, deductOrderStock } = require('../utils/orderUtils');
|
| 27 |
+
const { syncOrderWithSheet } = require('../utils/googleSheetsService');
|
| 28 |
+
|
| 29 |
+
exports.getAllOrders = async (req, res) => {
|
| 30 |
+
try {
|
| 31 |
+
const orders = await Order.find()
|
| 32 |
+
.populate('user', 'name email address mobile')
|
| 33 |
+
.populate({
|
| 34 |
+
path: 'items.product',
|
| 35 |
+
select: 'nameAr nameEn imageCover price provider',
|
| 36 |
+
populate: {
|
| 37 |
+
path: 'provider',
|
| 38 |
+
select: 'storeName name',
|
| 39 |
+
},
|
| 40 |
+
})
|
| 41 |
+
.sort({ createdAt: -1 })
|
| 42 |
+
.lean();
|
| 43 |
+
res.status(200).json({
|
| 44 |
+
status: 'success',
|
| 45 |
+
results: orders.length,
|
| 46 |
+
data: { orders },
|
| 47 |
+
});
|
| 48 |
+
} catch (err) {
|
| 49 |
+
res.status(500).json({
|
| 50 |
+
status: 'error',
|
| 51 |
+
message: err.message,
|
| 52 |
+
});
|
| 53 |
+
}
|
| 54 |
+
};
|
| 55 |
+
|
| 56 |
+
// Get orders for the authenticated user only
|
| 57 |
+
exports.getMyOrders = async (req, res) => {
|
| 58 |
+
try {
|
| 59 |
+
if (!req.user) {
|
| 60 |
+
return res.status(401).json({
|
| 61 |
+
status: 'fail',
|
| 62 |
+
message: 'You must be logged in to view your orders',
|
| 63 |
+
});
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
const orders = await Order.find({ user: req.user._id })
|
| 67 |
+
.populate('items.product', 'nameAr nameEn imageCover price')
|
| 68 |
+
.sort({ createdAt: -1 })
|
| 69 |
+
.lean();
|
| 70 |
+
|
| 71 |
+
res.status(200).json({
|
| 72 |
+
status: 'success',
|
| 73 |
+
results: orders.length,
|
| 74 |
+
data: { orders },
|
| 75 |
+
});
|
| 76 |
+
} catch (err) {
|
| 77 |
+
res.status(500).json({
|
| 78 |
+
status: 'error',
|
| 79 |
+
message: err.message,
|
| 80 |
+
});
|
| 81 |
+
}
|
| 82 |
+
};
|
| 83 |
+
|
| 84 |
+
exports.getOrder = async (req, res) => {
|
| 85 |
+
try {
|
| 86 |
+
const order = await Order.findById(req.params.id)
|
| 87 |
+
.populate({
|
| 88 |
+
path: 'items.product',
|
| 89 |
+
populate: {
|
| 90 |
+
path: 'provider',
|
| 91 |
+
model: 'Provider',
|
| 92 |
+
},
|
| 93 |
+
})
|
| 94 |
+
.populate('user', 'name email mobile address')
|
| 95 |
+
.lean();
|
| 96 |
+
|
| 97 |
+
if (!order) {
|
| 98 |
+
return res.status(404).json({
|
| 99 |
+
status: 'fail',
|
| 100 |
+
message: 'No order found',
|
| 101 |
+
});
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
// Authorization check
|
| 105 |
+
let isAuthorized = false;
|
| 106 |
+
|
| 107 |
+
if (req.user) {
|
| 108 |
+
// 1) Admins and employees with manage_orders permission can see any order
|
| 109 |
+
const isAdmin = req.user.role === 'admin';
|
| 110 |
+
const isEmployee =
|
| 111 |
+
req.user.role === 'employee' &&
|
| 112 |
+
req.user.permissions.includes(PERMISSIONS.MANAGE_ORDERS);
|
| 113 |
+
|
| 114 |
+
// 2) Users can see their own orders
|
| 115 |
+
const orderUserId = (order.user && order.user._id) || order.user;
|
| 116 |
+
const isOwner =
|
| 117 |
+
orderUserId && String(orderUserId) === String(req.user._id);
|
| 118 |
+
|
| 119 |
+
// 3) Vendors can see the order if it contains one of their products
|
| 120 |
+
let isVendorOfThisOrder = false;
|
| 121 |
+
if (req.user.role === 'vendor' && req.user.provider) {
|
| 122 |
+
const vendorProviderId = String(
|
| 123 |
+
req.user.provider._id || req.user.provider,
|
| 124 |
+
);
|
| 125 |
+
isVendorOfThisOrder =
|
| 126 |
+
order.items &&
|
| 127 |
+
order.items.some((item) => {
|
| 128 |
+
if (!item.product) return false;
|
| 129 |
+
const itemProviderId = String(
|
| 130 |
+
(item.product &&
|
| 131 |
+
item.product.provider &&
|
| 132 |
+
item.product.provider._id) ||
|
| 133 |
+
(item.product && item.product.provider) ||
|
| 134 |
+
'',
|
| 135 |
+
);
|
| 136 |
+
return itemProviderId === vendorProviderId;
|
| 137 |
+
});
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
if (isAdmin || isEmployee || isOwner || isVendorOfThisOrder) {
|
| 141 |
+
isAuthorized = true;
|
| 142 |
+
}
|
| 143 |
+
} else {
|
| 144 |
+
// Guest Access:
|
| 145 |
+
// If the order has NO user attached, we assume it's a guest order and allow access (since they have the ID)
|
| 146 |
+
if (!order.user) {
|
| 147 |
+
isAuthorized = true;
|
| 148 |
+
}
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
if (!isAuthorized) {
|
| 152 |
+
return res.status(403).json({
|
| 153 |
+
status: 'fail',
|
| 154 |
+
message: 'You are not authorized to view this order',
|
| 155 |
+
});
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
res.status(200).json({
|
| 159 |
+
status: 'success',
|
| 160 |
+
data: { order },
|
| 161 |
+
});
|
| 162 |
+
} catch (err) {
|
| 163 |
+
res.status(500).json({
|
| 164 |
+
status: 'error',
|
| 165 |
+
message: err.message,
|
| 166 |
+
});
|
| 167 |
+
}
|
| 168 |
+
};
|
| 169 |
+
|
| 170 |
+
exports.updateOrder = async (req, res) => {
|
| 171 |
+
try {
|
| 172 |
+
// Get the old order to check if status changed
|
| 173 |
+
const oldOrder = await Order.findById(req.params.id);
|
| 174 |
+
|
| 175 |
+
if (!oldOrder) {
|
| 176 |
+
return res
|
| 177 |
+
.status(404)
|
| 178 |
+
.json({ status: 'fail', message: 'No order found' });
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
// Prevent moving order away from 'cancelled' if any items are cancelled
|
| 182 |
+
if (req.body.orderStatus && req.body.orderStatus !== 'cancelled') {
|
| 183 |
+
const hasCancelledItems = oldOrder.items.some(
|
| 184 |
+
(item) => item.fulfillmentStatus === 'cancelled',
|
| 185 |
+
);
|
| 186 |
+
if (hasCancelledItems) {
|
| 187 |
+
// If it's an admin, we allow it BUT we must reset the items and handle stock
|
| 188 |
+
if (req.user && req.user.role === 'admin') {
|
| 189 |
+
// Reset all cancelled items to pending
|
| 190 |
+
const updatedItems = oldOrder.items.map((item) => {
|
| 191 |
+
if (item.fulfillmentStatus === 'cancelled') {
|
| 192 |
+
return { ...item.toObject(), fulfillmentStatus: 'pending' };
|
| 193 |
+
}
|
| 194 |
+
return item.toObject();
|
| 195 |
+
});
|
| 196 |
+
req.body.items = updatedItems;
|
| 197 |
+
|
| 198 |
+
// If moving from 'cancelled' order status, deduct stock again
|
| 199 |
+
if (oldOrder.orderStatus === 'cancelled') {
|
| 200 |
+
await deductOrderStock(oldOrder.items);
|
| 201 |
+
}
|
| 202 |
+
} else {
|
| 203 |
+
return res.status(400).json({
|
| 204 |
+
status: 'fail',
|
| 205 |
+
message:
|
| 206 |
+
'نعتذر، لا يمكن تغيير حالة الطلب بينما توجد منتجات ملغاة. يجب معالجة المنتجات الملغاة أولاً.',
|
| 207 |
+
});
|
| 208 |
+
}
|
| 209 |
+
}
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
// Handle moving TO cancelled status - propagate to all items
|
| 213 |
+
if (
|
| 214 |
+
req.body.orderStatus === 'cancelled' &&
|
| 215 |
+
oldOrder.orderStatus !== 'cancelled'
|
| 216 |
+
) {
|
| 217 |
+
const updatedItems = oldOrder.items.map((item) => ({
|
| 218 |
+
...item.toObject(),
|
| 219 |
+
fulfillmentStatus: 'cancelled',
|
| 220 |
+
}));
|
| 221 |
+
req.body.items = updatedItems;
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
// Check if status is being changed to shipped or completed and all items are ready
|
| 225 |
+
if (
|
| 226 |
+
req.body.orderStatus &&
|
| 227 |
+
['shipped', 'completed'].includes(req.body.orderStatus)
|
| 228 |
+
) {
|
| 229 |
+
const allReady = oldOrder.items.every(
|
| 230 |
+
(item) => item.fulfillmentStatus === 'ready',
|
| 231 |
+
);
|
| 232 |
+
if (!allReady) {
|
| 233 |
+
return res.status(400).json({
|
| 234 |
+
status: 'fail',
|
| 235 |
+
message:
|
| 236 |
+
'Cannot change order status to shipped/completed until all items are marked as ready.',
|
| 237 |
+
});
|
| 238 |
+
}
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
const updatedOrder = await Order.findByIdAndUpdate(
|
| 242 |
+
req.params.id,
|
| 243 |
+
req.body,
|
| 244 |
+
{ new: true, runValidators: true },
|
| 245 |
+
)
|
| 246 |
+
.populate({
|
| 247 |
+
path: 'items.product',
|
| 248 |
+
populate: {
|
| 249 |
+
path: 'provider',
|
| 250 |
+
model: 'Provider',
|
| 251 |
+
},
|
| 252 |
+
})
|
| 253 |
+
.populate('user', 'name email');
|
| 254 |
+
|
| 255 |
+
// Check if order status changed and send email + notification
|
| 256 |
+
if (
|
| 257 |
+
req.body.orderStatus &&
|
| 258 |
+
req.body.orderStatus !== oldOrder.orderStatus &&
|
| 259 |
+
updatedOrder.user
|
| 260 |
+
) {
|
| 261 |
+
// If status changed to cancelled, restore stock
|
| 262 |
+
if (req.body.orderStatus === 'cancelled') {
|
| 263 |
+
await restoreOrderStock(updatedOrder.items);
|
| 264 |
+
|
| 265 |
+
// ── Capital: Reverse profit if order was previously completed ──
|
| 266 |
+
if (oldOrder.orderStatus === 'completed') {
|
| 267 |
+
try {
|
| 268 |
+
const populatedForReversal = await Order.findById(
|
| 269 |
+
updatedOrder._id,
|
| 270 |
+
).populate({
|
| 271 |
+
path: 'items.product',
|
| 272 |
+
select: 'costPrice',
|
| 273 |
+
});
|
| 274 |
+
|
| 275 |
+
let reversedProfit = 0;
|
| 276 |
+
for (const item of populatedForReversal.items) {
|
| 277 |
+
if (!item.product) continue;
|
| 278 |
+
const costPrice = item.product.costPrice || 0;
|
| 279 |
+
const sellingPrice = item.unitPrice;
|
| 280 |
+
reversedProfit += (sellingPrice - costPrice) * item.quantity;
|
| 281 |
+
}
|
| 282 |
+
|
| 283 |
+
if (reversedProfit > 0) {
|
| 284 |
+
const capital = await Capital.getCapital();
|
| 285 |
+
capital.currentCapital -= reversedProfit;
|
| 286 |
+
capital.logs.push({
|
| 287 |
+
type: 'adjustment',
|
| 288 |
+
amount: -reversedProfit,
|
| 289 |
+
balanceAfter: capital.currentCapital,
|
| 290 |
+
reference: updatedOrder._id,
|
| 291 |
+
referenceModel: 'Order',
|
| 292 |
+
description: `Reversed profit — completed order #${updatedOrder._id.toString().slice(-8).toUpperCase()} was cancelled`,
|
| 293 |
+
date: new Date(),
|
| 294 |
+
createdBy: req.user._id,
|
| 295 |
+
});
|
| 296 |
+
await capital.save();
|
| 297 |
+
}
|
| 298 |
+
} catch (capitalErr) {
|
| 299 |
+
console.error(
|
| 300 |
+
'Failed to reverse capital on order cancellation:',
|
| 301 |
+
capitalErr.message,
|
| 302 |
+
);
|
| 303 |
+
}
|
| 304 |
+
}
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
// Prepare order data for email
|
| 308 |
+
const emailOrderData = {
|
| 309 |
+
orderNumber: updatedOrder._id.toString().slice(-8).toUpperCase(),
|
| 310 |
+
total: updatedOrder.totalPrice,
|
| 311 |
+
};
|
| 312 |
+
|
| 313 |
+
// Send status update email (don't wait for it)
|
| 314 |
+
sendOrderStatusUpdateEmail(
|
| 315 |
+
emailOrderData,
|
| 316 |
+
updatedOrder.user,
|
| 317 |
+
req.body.orderStatus,
|
| 318 |
+
).catch(() => { });
|
| 319 |
+
|
| 320 |
+
// Send notification based on status
|
| 321 |
+
const userId = updatedOrder.user._id || updatedOrder.user;
|
| 322 |
+
try {
|
| 323 |
+
switch (req.body.orderStatus) {
|
| 324 |
+
case 'created':
|
| 325 |
+
await notifyOrderCreated(updatedOrder, userId);
|
| 326 |
+
break;
|
| 327 |
+
case 'processing':
|
| 328 |
+
await notifyOrderProcessing(updatedOrder, userId);
|
| 329 |
+
break;
|
| 330 |
+
case 'shipped':
|
| 331 |
+
await notifyOrderShipped(updatedOrder, userId);
|
| 332 |
+
break;
|
| 333 |
+
case 'completed':
|
| 334 |
+
await notifyOrderCompleted(updatedOrder, userId);
|
| 335 |
+
break;
|
| 336 |
+
case 'cancelled':
|
| 337 |
+
await notifyOrderCancelled(updatedOrder, userId);
|
| 338 |
+
break;
|
| 339 |
+
default:
|
| 340 |
+
break;
|
| 341 |
+
}
|
| 342 |
+
} catch (notifErr) {
|
| 343 |
+
// Silent fail
|
| 344 |
+
}
|
| 345 |
+
|
| 346 |
+
// ── Capital: Add profit when order is marked completed ──
|
| 347 |
+
if (req.body.orderStatus === 'completed') {
|
| 348 |
+
try {
|
| 349 |
+
const populatedForProfit = await Order.findById(
|
| 350 |
+
updatedOrder._id,
|
| 351 |
+
).populate({
|
| 352 |
+
path: 'items.product',
|
| 353 |
+
select: 'costPrice purchasePrice',
|
| 354 |
+
});
|
| 355 |
+
|
| 356 |
+
let orderProfit = 0;
|
| 357 |
+
for (const item of populatedForProfit.items) {
|
| 358 |
+
if (!item.product) continue;
|
| 359 |
+
const costPrice = item.product.costPrice || 0;
|
| 360 |
+
const sellingPrice = item.unitPrice;
|
| 361 |
+
orderProfit += (sellingPrice - costPrice) * item.quantity;
|
| 362 |
+
}
|
| 363 |
+
|
| 364 |
+
if (orderProfit > 0) {
|
| 365 |
+
const capital = await Capital.getCapital();
|
| 366 |
+
capital.currentCapital += orderProfit;
|
| 367 |
+
capital.logs.push({
|
| 368 |
+
type: 'profit',
|
| 369 |
+
amount: orderProfit,
|
| 370 |
+
balanceAfter: capital.currentCapital,
|
| 371 |
+
reference: updatedOrder._id,
|
| 372 |
+
referenceModel: 'Order',
|
| 373 |
+
description: `Profit from completed order #${updatedOrder._id.toString().slice(-8).toUpperCase()}`,
|
| 374 |
+
date: new Date(),
|
| 375 |
+
createdBy: req.user._id,
|
| 376 |
+
});
|
| 377 |
+
await capital.save();
|
| 378 |
+
}
|
| 379 |
+
} catch (capitalErr) {
|
| 380 |
+
// Silent fail — don't block order update
|
| 381 |
+
console.error(
|
| 382 |
+
'Failed to update capital on order completion:',
|
| 383 |
+
capitalErr.message,
|
| 384 |
+
);
|
| 385 |
+
}
|
| 386 |
+
}
|
| 387 |
+
|
| 388 |
+
// Explicitly broadcast order update for the dashboard (ONLY ONCE)
|
| 389 |
+
emitOrderUpdate(updatedOrder, req.body.orderStatus);
|
| 390 |
+
}
|
| 391 |
+
|
| 392 |
+
res.status(200).json({ status: 'success', data: { order: updatedOrder } });
|
| 393 |
+
|
| 394 |
+
// Sync to Google Sheet (live update)
|
| 395 |
+
syncOrderWithSheet(updatedOrder).catch(() => {});
|
| 396 |
+
} catch (err) {
|
| 397 |
+
res.status(500).json({ status: 'error', message: err.message });
|
| 398 |
+
}
|
| 399 |
+
};
|
| 400 |
+
|
| 401 |
+
exports.cancelOrder = async (req, res) => {
|
| 402 |
+
try {
|
| 403 |
+
const { id } = req.params;
|
| 404 |
+
const order = await Order.findById(id);
|
| 405 |
+
if (!order)
|
| 406 |
+
return res
|
| 407 |
+
.status(404)
|
| 408 |
+
.json({ status: 'fail', message: 'Order not found' });
|
| 409 |
+
|
| 410 |
+
if (!req.user)
|
| 411 |
+
return res.status(401).json({
|
| 412 |
+
status: 'fail',
|
| 413 |
+
message: 'You must be logged in to cancel an order',
|
| 414 |
+
});
|
| 415 |
+
|
| 416 |
+
const orderUserId = (order.user && order.user._id) || order.user;
|
| 417 |
+
const isOwner = orderUserId && String(orderUserId) === String(req.user._id);
|
| 418 |
+
const isAdmin = req.user.role === 'admin';
|
| 419 |
+
if (!isOwner && !isAdmin)
|
| 420 |
+
return res.status(403).json({
|
| 421 |
+
status: 'fail',
|
| 422 |
+
message: 'Not authorized to cancel this order',
|
| 423 |
+
});
|
| 424 |
+
|
| 425 |
+
if (order.orderStatus === 'cancelled') {
|
| 426 |
+
return res
|
| 427 |
+
.status(400)
|
| 428 |
+
.json({ status: 'fail', message: 'Order is already cancelled' });
|
| 429 |
+
}
|
| 430 |
+
|
| 431 |
+
await restoreOrderStock(order.items);
|
| 432 |
+
|
| 433 |
+
if (order.promo) {
|
| 434 |
+
try {
|
| 435 |
+
await Promo.findByIdAndUpdate(order.promo, { $inc: { usedCount: -1 } });
|
| 436 |
+
} catch (promoErr) {
|
| 437 |
+
console.error('Failed to restore promo usage:', promoErr);
|
| 438 |
+
}
|
| 439 |
+
}
|
| 440 |
+
|
| 441 |
+
order.orderStatus = 'cancelled';
|
| 442 |
+
order.canceledAt = new Date();
|
| 443 |
+
|
| 444 |
+
if (order.payment && order.payment.status !== 'failed') {
|
| 445 |
+
order.payment.status = 'failed';
|
| 446 |
+
}
|
| 447 |
+
|
| 448 |
+
await order.save();
|
| 449 |
+
|
| 450 |
+
// Send cancellation notification
|
| 451 |
+
try {
|
| 452 |
+
const userId = order.user;
|
| 453 |
+
await notifyOrderCancelled(order, userId, 'Order cancelled as requested');
|
| 454 |
+
} catch (notifErr) {
|
| 455 |
+
// Silent fail
|
| 456 |
+
}
|
| 457 |
+
|
| 458 |
+
// Explicitly broadcast order update for the dashboard (ONLY ONCE)
|
| 459 |
+
emitOrderUpdate(order, 'order_cancelled');
|
| 460 |
+
|
| 461 |
+
// Populate product details before returning
|
| 462 |
+
await order.populate('items.product', 'nameAr nameEn imageCover price');
|
| 463 |
+
|
| 464 |
+
res.status(200).json({ status: 'success', data: { order } });
|
| 465 |
+
|
| 466 |
+
// Sync to Google Sheet (live update)
|
| 467 |
+
syncOrderWithSheet(order).catch(() => {});
|
| 468 |
+
} catch (err) {
|
| 469 |
+
res.status(500).json({ status: 'error', message: err.message });
|
| 470 |
+
}
|
| 471 |
+
};
|
| 472 |
+
|
| 473 |
+
exports.deleteOrder = async (req, res) => {
|
| 474 |
+
try {
|
| 475 |
+
const order = await Order.findByIdAndDelete(req.params.id);
|
| 476 |
+
if (!order) {
|
| 477 |
+
return res.status(404).json({
|
| 478 |
+
status: 'fail',
|
| 479 |
+
message: 'No order found',
|
| 480 |
+
});
|
| 481 |
+
}
|
| 482 |
+
|
| 483 |
+
// Explicitly broadcast order update for the dashboard
|
| 484 |
+
emitOrderUpdate({ _id: req.params.id }, 'order_deleted');
|
| 485 |
+
|
| 486 |
+
res.status(204).json({
|
| 487 |
+
status: 'success',
|
| 488 |
+
data: null,
|
| 489 |
+
});
|
| 490 |
+
} catch (err) {
|
| 491 |
+
res.status(500).json({
|
| 492 |
+
status: 'error',
|
| 493 |
+
message: err.message,
|
| 494 |
+
});
|
| 495 |
+
}
|
| 496 |
+
};
|
| 497 |
+
|
| 498 |
+
exports.getOrderSuccessPage = async (req, res) => {
|
| 499 |
+
try {
|
| 500 |
+
const { id } = req.params;
|
| 501 |
+
const order = await Order.findById(id).populate('items.product').lean();
|
| 502 |
+
if (!order)
|
| 503 |
+
return res
|
| 504 |
+
.status(404)
|
| 505 |
+
.json({ status: 'fail', message: 'Order not found' });
|
| 506 |
+
|
| 507 |
+
res.status(200).json({
|
| 508 |
+
status: 'success',
|
| 509 |
+
data: {
|
| 510 |
+
message: 'Order placed successfully',
|
| 511 |
+
order,
|
| 512 |
+
},
|
| 513 |
+
});
|
| 514 |
+
} catch (err) {
|
| 515 |
+
res.status(500).json({ status: 'error', message: err.message });
|
| 516 |
+
}
|
| 517 |
+
};
|
| 518 |
+
|
| 519 |
+
function computeDiscountAmount(type, value, base) {
|
| 520 |
+
const numericBase = Number(base) || 0;
|
| 521 |
+
const numericValue = Number(value) || 0;
|
| 522 |
+
if (type === 'shipping') return 0; // Does not affect subtotal base
|
| 523 |
+
if (type === 'percentage')
|
| 524 |
+
return Math.max(0, (numericBase * numericValue) / 100);
|
| 525 |
+
return Math.max(0, numericValue);
|
| 526 |
+
}
|
| 527 |
+
|
| 528 |
+
const SiteSettings = require('../models/siteSettingsModel');
|
| 529 |
+
|
| 530 |
+
exports.createOrder = async (req, res) => {
|
| 531 |
+
try {
|
| 532 |
+
const { name, mobile, address, payment, items, promoCode, source } = req.body;
|
| 533 |
+
|
| 534 |
+
// Check guest checkout settings
|
| 535 |
+
if (!req.user) {
|
| 536 |
+
const settings = await SiteSettings.getSettings();
|
| 537 |
+
if (!settings.checkout.allowGuestCheckout) {
|
| 538 |
+
return res.status(401).json({
|
| 539 |
+
status: 'fail',
|
| 540 |
+
message:
|
| 541 |
+
'Guest checkout is disabled. Please log in or create an account to place an order.',
|
| 542 |
+
});
|
| 543 |
+
}
|
| 544 |
+
}
|
| 545 |
+
|
| 546 |
+
if (!items || !Array.isArray(items) || items.length === 0) {
|
| 547 |
+
return res
|
| 548 |
+
.status(400)
|
| 549 |
+
.json({ status: 'fail', message: 'Cart items are required' });
|
| 550 |
+
}
|
| 551 |
+
|
| 552 |
+
if (payment && payment.method === 'visa' && !req.user) {
|
| 553 |
+
return res.status(400).json({
|
| 554 |
+
status: 'fail',
|
| 555 |
+
message: 'You must be logged in to pay with Visa',
|
| 556 |
+
});
|
| 557 |
+
}
|
| 558 |
+
|
| 559 |
+
const productIds = items.map((i) => i.productId);
|
| 560 |
+
const products = await Product.find({ _id: { $in: productIds } });
|
| 561 |
+
const productMap = new Map(products.map((p) => [String(p._id), p]));
|
| 562 |
+
|
| 563 |
+
const missingIds = productIds.filter((id) => !productMap.has(String(id)));
|
| 564 |
+
if (missingIds.length > 0) {
|
| 565 |
+
return res.status(400).json({
|
| 566 |
+
status: 'fail',
|
| 567 |
+
message: 'Some products were not found',
|
| 568 |
+
missingProductIds: missingIds,
|
| 569 |
+
});
|
| 570 |
+
}
|
| 571 |
+
|
| 572 |
+
// Decrease stock for each product and track out-of-stock items
|
| 573 |
+
// Decrease stock for each product in bulk
|
| 574 |
+
const bulkOps = items.map((i) => ({
|
| 575 |
+
updateOne: {
|
| 576 |
+
filter: { _id: i.productId },
|
| 577 |
+
update: { $inc: { stock: -(Number(i.quantity) || 1) } },
|
| 578 |
+
},
|
| 579 |
+
}));
|
| 580 |
+
await Product.bulkWrite(bulkOps);
|
| 581 |
+
|
| 582 |
+
// Identify products that went out of stock
|
| 583 |
+
const outOfStockItems = await Product.find({
|
| 584 |
+
_id: { $in: productIds },
|
| 585 |
+
stock: { $lte: 0 },
|
| 586 |
+
});
|
| 587 |
+
|
| 588 |
+
let subtotal = 0;
|
| 589 |
+
const orderItems = [];
|
| 590 |
+
for (const i of items) {
|
| 591 |
+
const prod = productMap.get(String(i.productId));
|
| 592 |
+
const quantity = Number(i.quantity) || 1;
|
| 593 |
+
const unitPrice =
|
| 594 |
+
prod.salePrice && prod.salePrice < prod.price
|
| 595 |
+
? prod.salePrice
|
| 596 |
+
: prod.price;
|
| 597 |
+
subtotal += unitPrice * quantity;
|
| 598 |
+
orderItems.push({
|
| 599 |
+
product: prod._id,
|
| 600 |
+
name: prod.nameAr || prod.nameEn || prod.barCode,
|
| 601 |
+
quantity,
|
| 602 |
+
unitPrice,
|
| 603 |
+
});
|
| 604 |
+
}
|
| 605 |
+
|
| 606 |
+
// Dynamic Shipping Calculation:
|
| 607 |
+
// Strictly find price for the governorate level
|
| 608 |
+
const govPrice = await ShippingPrice.findOne({
|
| 609 |
+
$or: [
|
| 610 |
+
{ areaNameAr: address.governorate },
|
| 611 |
+
{
|
| 612 |
+
areaNameEn: {
|
| 613 |
+
$regex: new RegExp(`^${address.governorate.trim()}$`, 'i'),
|
| 614 |
+
},
|
| 615 |
+
},
|
| 616 |
+
],
|
| 617 |
+
type: 'city',
|
| 618 |
+
});
|
| 619 |
+
|
| 620 |
+
const SHIPPING_COST = govPrice ? govPrice.fees : 300;
|
| 621 |
+
const deliveryDays = govPrice ? govPrice.deliveryDays : 3;
|
| 622 |
+
|
| 623 |
+
// Calculate estimated delivery date
|
| 624 |
+
const estimatedDeliveryDate = new Date();
|
| 625 |
+
estimatedDeliveryDate.setDate(
|
| 626 |
+
estimatedDeliveryDate.getDate() + deliveryDays,
|
| 627 |
+
);
|
| 628 |
+
|
| 629 |
+
let totalPrice = subtotal + SHIPPING_COST;
|
| 630 |
+
|
| 631 |
+
let appliedPromo = null;
|
| 632 |
+
let discountAmountApplied = 0;
|
| 633 |
+
if (promoCode) {
|
| 634 |
+
const upper = String(promoCode).trim().toUpperCase();
|
| 635 |
+
const now = new Date();
|
| 636 |
+
|
| 637 |
+
const promo = await Promo.findOne({
|
| 638 |
+
code: upper,
|
| 639 |
+
active: true,
|
| 640 |
+
$and: [
|
| 641 |
+
{ $or: [{ startsAt: null }, { startsAt: { $lte: now } }] },
|
| 642 |
+
{ $or: [{ expiresAt: null }, { expiresAt: { $gt: now } }] },
|
| 643 |
+
],
|
| 644 |
+
minOrderValue: { $lte: subtotal },
|
| 645 |
+
});
|
| 646 |
+
|
| 647 |
+
if (!promo)
|
| 648 |
+
return res
|
| 649 |
+
.status(400)
|
| 650 |
+
.json({ status: 'fail', message: 'Invalid or expired promo code' });
|
| 651 |
+
|
| 652 |
+
if (promo.perUserLimit && promo.perUserLimit > 0) {
|
| 653 |
+
if (!req.user)
|
| 654 |
+
return res.status(400).json({
|
| 655 |
+
status: 'fail',
|
| 656 |
+
message: 'You must be logged in to use this promo code',
|
| 657 |
+
});
|
| 658 |
+
const userUses = await Order.countDocuments({
|
| 659 |
+
user: req.user._id,
|
| 660 |
+
promo: promo._id,
|
| 661 |
+
});
|
| 662 |
+
if (userUses >= promo.perUserLimit)
|
| 663 |
+
return res.status(400).json({
|
| 664 |
+
status: 'fail',
|
| 665 |
+
message: 'Promo code usage limit reached for this user',
|
| 666 |
+
});
|
| 667 |
+
}
|
| 668 |
+
|
| 669 |
+
if (promo.usageLimit != null && promo.usedCount >= promo.usageLimit)
|
| 670 |
+
return res
|
| 671 |
+
.status(400)
|
| 672 |
+
.json({ status: 'fail', message: 'Promo code has been fully used' });
|
| 673 |
+
|
| 674 |
+
await Promo.findByIdAndUpdate(promo._id, { $inc: { usedCount: 1 } });
|
| 675 |
+
appliedPromo = promo;
|
| 676 |
+
|
| 677 |
+
const discount = computeDiscountAmount(promo.type, promo.value, subtotal);
|
| 678 |
+
discountAmountApplied = discount;
|
| 679 |
+
|
| 680 |
+
if (promo.type === 'shipping') {
|
| 681 |
+
totalPrice = subtotal; // Shipping is free
|
| 682 |
+
discountAmountApplied = SHIPPING_COST; // Record the shipping saving as the discount
|
| 683 |
+
} else {
|
| 684 |
+
totalPrice = Math.max(0, subtotal - discount) + SHIPPING_COST;
|
| 685 |
+
}
|
| 686 |
+
}
|
| 687 |
+
|
| 688 |
+
const normalizedPayment = { ...payment };
|
| 689 |
+
// Don't automatically mark visa payments as paid - wait for Paymob callback
|
| 690 |
+
if (!normalizedPayment || !normalizedPayment.status) {
|
| 691 |
+
normalizedPayment.status = 'pending';
|
| 692 |
+
}
|
| 693 |
+
|
| 694 |
+
let promoData = {};
|
| 695 |
+
if (promoCode && appliedPromo) {
|
| 696 |
+
const discountAmount = Number(discountAmountApplied.toFixed(2));
|
| 697 |
+
promoData = {
|
| 698 |
+
promo: appliedPromo._id,
|
| 699 |
+
promoCode: appliedPromo.code,
|
| 700 |
+
discountAmount,
|
| 701 |
+
discountType: appliedPromo.type,
|
| 702 |
+
};
|
| 703 |
+
}
|
| 704 |
+
|
| 705 |
+
const orderData = {
|
| 706 |
+
name,
|
| 707 |
+
mobile,
|
| 708 |
+
address,
|
| 709 |
+
items: orderItems,
|
| 710 |
+
payment: normalizedPayment,
|
| 711 |
+
totalPrice,
|
| 712 |
+
estimatedDeliveryDate,
|
| 713 |
+
shippingPrice: SHIPPING_COST,
|
| 714 |
+
source: source || 'direct',
|
| 715 |
+
...promoData,
|
| 716 |
+
};
|
| 717 |
+
|
| 718 |
+
if (req.user) orderData.user = req.user._id;
|
| 719 |
+
|
| 720 |
+
let newOrder;
|
| 721 |
+
try {
|
| 722 |
+
newOrder = await Order.create(orderData);
|
| 723 |
+
} catch (err) {
|
| 724 |
+
// Rollback: Restore stock if order creation fails
|
| 725 |
+
try {
|
| 726 |
+
await restoreOrderStock(orderItems);
|
| 727 |
+
} catch (restoreErr) {
|
| 728 |
+
console.error(
|
| 729 |
+
'Failed to restore stock after order creation error:',
|
| 730 |
+
restoreErr,
|
| 731 |
+
);
|
| 732 |
+
}
|
| 733 |
+
|
| 734 |
+
if (appliedPromo) {
|
| 735 |
+
try {
|
| 736 |
+
await Promo.findByIdAndUpdate(appliedPromo._id, {
|
| 737 |
+
$inc: { usedCount: -1 },
|
| 738 |
+
});
|
| 739 |
+
} catch (err2) {
|
| 740 |
+
// Silent fail or use a proper logger
|
| 741 |
+
}
|
| 742 |
+
}
|
| 743 |
+
throw err;
|
| 744 |
+
}
|
| 745 |
+
|
| 746 |
+
// Send notifications (ONLY for CASH orders)
|
| 747 |
+
// For Visa, we notify only after successful payment confirmation in paymentController
|
| 748 |
+
if (newOrder.payment.method === 'cash') {
|
| 749 |
+
try {
|
| 750 |
+
if (req.user) {
|
| 751 |
+
notifyOrderCreated(newOrder, req.user._id).catch(() => { });
|
| 752 |
+
}
|
| 753 |
+
|
| 754 |
+
const vendorIds = new Set();
|
| 755 |
+
products.forEach((p) => {
|
| 756 |
+
if (p.provider) vendorIds.add(p.provider.toString());
|
| 757 |
+
});
|
| 758 |
+
|
| 759 |
+
vendorIds.forEach((vId) => {
|
| 760 |
+
notifyVendorNewOrder(newOrder, vId).catch(() => { });
|
| 761 |
+
});
|
| 762 |
+
|
| 763 |
+
notifyAdminsNewOrder(newOrder).catch(() => { });
|
| 764 |
+
} catch (notifErr) {
|
| 765 |
+
// Silent fail
|
| 766 |
+
}
|
| 767 |
+
}
|
| 768 |
+
|
| 769 |
+
// Out-of-stock notifications should happen regardless of payment method
|
| 770 |
+
// because stock IS deducted from the DB now
|
| 771 |
+
try {
|
| 772 |
+
outOfStockItems.forEach((prod) => {
|
| 773 |
+
if (prod.provider) {
|
| 774 |
+
notifyProductOutOfStock(prod, prod.provider).catch(() => { });
|
| 775 |
+
}
|
| 776 |
+
notifyAdminsProductOutOfStock(prod).catch(() => { });
|
| 777 |
+
});
|
| 778 |
+
} catch (vendorNotifErr) {
|
| 779 |
+
// Silent fail
|
| 780 |
+
}
|
| 781 |
+
|
| 782 |
+
// Explicitly broadcast order update for the dashboard (ONLY ONCE)
|
| 783 |
+
emitOrderUpdate(newOrder, 'order_created');
|
| 784 |
+
|
| 785 |
+
res.status(201).json({
|
| 786 |
+
status: 'success',
|
| 787 |
+
data: { order: newOrder },
|
| 788 |
+
});
|
| 789 |
+
|
| 790 |
+
// Sync to Google Sheet (live update)
|
| 791 |
+
syncOrderWithSheet(newOrder).catch(() => {});
|
| 792 |
+
|
| 793 |
+
// Send order confirmation email (non-blocking - after response)
|
| 794 |
+
// ONLY for cash orders. Visa orders will send once payment is confirmed.
|
| 795 |
+
if (req.user && newOrder.payment.method === 'cash') {
|
| 796 |
+
// Populate order with product details and provider for email
|
| 797 |
+
const populatedOrder = await Order.findById(newOrder._id).populate({
|
| 798 |
+
path: 'items.product',
|
| 799 |
+
select: 'nameAr price imageCover',
|
| 800 |
+
populate: {
|
| 801 |
+
path: 'provider',
|
| 802 |
+
select: 'storeName',
|
| 803 |
+
},
|
| 804 |
+
});
|
| 805 |
+
|
| 806 |
+
// Prepare order data for email template
|
| 807 |
+
const emailOrderData = {
|
| 808 |
+
orderNumber: newOrder._id.toString().slice(-8).toUpperCase(), // Last 8 chars of ID
|
| 809 |
+
items: populatedOrder.items.map((item) => ({
|
| 810 |
+
product: {
|
| 811 |
+
nameAr:
|
| 812 |
+
item.product && item.product.nameAr
|
| 813 |
+
? item.product.nameAr
|
| 814 |
+
: item.name,
|
| 815 |
+
imageCover:
|
| 816 |
+
item.product && item.product.imageCover
|
| 817 |
+
? item.product.imageCover
|
| 818 |
+
: null,
|
| 819 |
+
provider:
|
| 820 |
+
item.product && item.product.provider
|
| 821 |
+
? item.product.provider
|
| 822 |
+
: null,
|
| 823 |
+
},
|
| 824 |
+
quantity: item.quantity,
|
| 825 |
+
price: item.unitPrice,
|
| 826 |
+
})),
|
| 827 |
+
subtotal: subtotal,
|
| 828 |
+
discount: discountAmountApplied,
|
| 829 |
+
shippingCost: SHIPPING_COST,
|
| 830 |
+
total: totalPrice,
|
| 831 |
+
paymentMethod: newOrder.payment.method,
|
| 832 |
+
shippingAddress: {
|
| 833 |
+
street: newOrder.address.street,
|
| 834 |
+
city: newOrder.address.city,
|
| 835 |
+
governorate: newOrder.address.governorate,
|
| 836 |
+
phone: newOrder.mobile,
|
| 837 |
+
},
|
| 838 |
+
};
|
| 839 |
+
|
| 840 |
+
sendOrderConfirmationEmail(emailOrderData, req.user).catch(() => { });
|
| 841 |
+
}
|
| 842 |
+
|
| 843 |
+
// Notify all admin users by email about the new order
|
| 844 |
+
User.find({ role: 'admin' })
|
| 845 |
+
.select('email name')
|
| 846 |
+
.lean()
|
| 847 |
+
.then((admins) => {
|
| 848 |
+
if (!admins || admins.length === 0) return;
|
| 849 |
+
|
| 850 |
+
// Reuse emailOrderData if already built (cash orders), otherwise build a minimal version
|
| 851 |
+
const adminOrderData = {
|
| 852 |
+
orderNumber: newOrder._id.toString().slice(-8).toUpperCase(),
|
| 853 |
+
items: newOrder.items.map((item) => ({
|
| 854 |
+
product: { nameAr: item.name, imageCover: null, provider: null },
|
| 855 |
+
quantity: item.quantity,
|
| 856 |
+
price: item.unitPrice,
|
| 857 |
+
})),
|
| 858 |
+
subtotal,
|
| 859 |
+
discount: discountAmountApplied,
|
| 860 |
+
shippingCost: SHIPPING_COST,
|
| 861 |
+
total: totalPrice,
|
| 862 |
+
paymentMethod: newOrder.payment.method,
|
| 863 |
+
shippingAddress: {
|
| 864 |
+
street: newOrder.address.street,
|
| 865 |
+
city: newOrder.address.city,
|
| 866 |
+
governorate: newOrder.address.governorate,
|
| 867 |
+
phone: newOrder.mobile,
|
| 868 |
+
},
|
| 869 |
+
};
|
| 870 |
+
|
| 871 |
+
const customer = req.user
|
| 872 |
+
? { name: req.user.name, email: req.user.email }
|
| 873 |
+
: { name: 'عميل غير مسجل', email: '-' };
|
| 874 |
+
|
| 875 |
+
admins.forEach((admin) => {
|
| 876 |
+
sendAdminNewOrderEmail(adminOrderData, customer, admin.email).catch(
|
| 877 |
+
() => { },
|
| 878 |
+
);
|
| 879 |
+
});
|
| 880 |
+
})
|
| 881 |
+
.catch(() => { });
|
| 882 |
+
} catch (err) {
|
| 883 |
+
res.status(500).json({
|
| 884 |
+
status: 'error',
|
| 885 |
+
message: err.message,
|
| 886 |
+
});
|
| 887 |
+
}
|
| 888 |
+
};
|
| 889 |
+
|
| 890 |
+
exports.updateOrderItemFulfillment = async (req, res) => {
|
| 891 |
+
try {
|
| 892 |
+
const { orderId, productId } = req.params;
|
| 893 |
+
const { fulfillmentStatus } = req.body;
|
| 894 |
+
|
| 895 |
+
// Validate fulfillment status
|
| 896 |
+
const validStatuses = ['pending', 'preparing', 'ready', 'cancelled'];
|
| 897 |
+
if (!validStatuses.includes(fulfillmentStatus)) {
|
| 898 |
+
return res
|
| 899 |
+
.status(400)
|
| 900 |
+
.json({ status: 'fail', message: 'Invalid fulfillment status' });
|
| 901 |
+
}
|
| 902 |
+
|
| 903 |
+
const order = await Order.findById(orderId).populate('items.product');
|
| 904 |
+
|
| 905 |
+
if (!order) {
|
| 906 |
+
return res
|
| 907 |
+
.status(404)
|
| 908 |
+
.json({ status: 'fail', message: 'Order not found' });
|
| 909 |
+
}
|
| 910 |
+
|
| 911 |
+
// Find the item
|
| 912 |
+
const item = order.items.find(
|
| 913 |
+
(it) => it.product && it.product._id.toString() === productId,
|
| 914 |
+
);
|
| 915 |
+
|
| 916 |
+
if (!item) {
|
| 917 |
+
return res
|
| 918 |
+
.status(404)
|
| 919 |
+
.json({ status: 'fail', message: 'Product not found in this order' });
|
| 920 |
+
}
|
| 921 |
+
|
| 922 |
+
// Authorization check
|
| 923 |
+
// Admins and employees with manage_orders can update anything
|
| 924 |
+
const isAdmin = req.user.role === 'admin';
|
| 925 |
+
const isEmployee =
|
| 926 |
+
req.user.role === 'employee' &&
|
| 927 |
+
req.user.permissions.includes(PERMISSIONS.MANAGE_ORDERS);
|
| 928 |
+
|
| 929 |
+
// For vendors, check if they own the product
|
| 930 |
+
if (!isAdmin && !isEmployee) {
|
| 931 |
+
if (req.user.role !== 'vendor' || !req.user.provider) {
|
| 932 |
+
return res
|
| 933 |
+
.status(403)
|
| 934 |
+
.json({ status: 'fail', message: 'Not authorized' });
|
| 935 |
+
}
|
| 936 |
+
|
| 937 |
+
const providerId = req.user.provider._id || req.user.provider;
|
| 938 |
+
if (item.product.provider.toString() !== providerId.toString()) {
|
| 939 |
+
return res.status(403).json({
|
| 940 |
+
status: 'fail',
|
| 941 |
+
message: 'You can only update your own items',
|
| 942 |
+
});
|
| 943 |
+
}
|
| 944 |
+
}
|
| 945 |
+
|
| 946 |
+
item.fulfillmentStatus = fulfillmentStatus;
|
| 947 |
+
|
| 948 |
+
// Handle cancellation
|
| 949 |
+
if (fulfillmentStatus === 'cancelled') {
|
| 950 |
+
order.orderStatus = 'cancelled';
|
| 951 |
+
// Restore stock for all items in the order
|
| 952 |
+
await restoreOrderStock(order.items);
|
| 953 |
+
} else if (
|
| 954 |
+
// Auto-revert order status to processing if an item is moved back from 'ready'
|
| 955 |
+
// when the order was already shipped or completed
|
| 956 |
+
fulfillmentStatus !== 'ready' &&
|
| 957 |
+
['shipped', 'completed'].includes(order.orderStatus)
|
| 958 |
+
) {
|
| 959 |
+
order.orderStatus = 'processing';
|
| 960 |
+
}
|
| 961 |
+
|
| 962 |
+
await order.save();
|
| 963 |
+
|
| 964 |
+
emitOrderUpdate(order, 'order_item_updated');
|
| 965 |
+
|
| 966 |
+
res.status(200).json({
|
| 967 |
+
status: 'success',
|
| 968 |
+
data: { order },
|
| 969 |
+
});
|
| 970 |
+
|
| 971 |
+
// Sync to Google Sheet (live update)
|
| 972 |
+
syncOrderWithSheet(order).catch(() => {});
|
| 973 |
+
} catch (err) {
|
| 974 |
+
res.status(500).json({ status: 'error', message: err.message });
|
| 975 |
+
}
|
| 976 |
+
};
|
| 977 |
+
|
| 978 |
+
exports.updateOrderItemQuantity = async (req, res) => {
|
| 979 |
+
try {
|
| 980 |
+
const { orderId, productId } = req.params;
|
| 981 |
+
const { quantity } = req.body;
|
| 982 |
+
|
| 983 |
+
if (!quantity || quantity < 1) {
|
| 984 |
+
return res.status(400).json({
|
| 985 |
+
status: 'fail',
|
| 986 |
+
message: 'Quantity must be at least 1',
|
| 987 |
+
});
|
| 988 |
+
}
|
| 989 |
+
|
| 990 |
+
const order = await Order.findById(orderId).populate('items.product');
|
| 991 |
+
|
| 992 |
+
if (!order) {
|
| 993 |
+
return res.status(404).json({
|
| 994 |
+
status: 'fail',
|
| 995 |
+
message: 'Order not found',
|
| 996 |
+
});
|
| 997 |
+
}
|
| 998 |
+
|
| 999 |
+
// Authorization check: Only admins or authorized employees can edit quantities
|
| 1000 |
+
const isAdmin = req.user.role === 'admin';
|
| 1001 |
+
const isEmployee =
|
| 1002 |
+
req.user.role === 'employee' &&
|
| 1003 |
+
req.user.permissions.includes(PERMISSIONS.MANAGE_ORDERS);
|
| 1004 |
+
|
| 1005 |
+
if (!isAdmin && !isEmployee) {
|
| 1006 |
+
return res.status(403).json({
|
| 1007 |
+
status: 'fail',
|
| 1008 |
+
message: 'Not authorized to change quantities',
|
| 1009 |
+
});
|
| 1010 |
+
}
|
| 1011 |
+
|
| 1012 |
+
// Find the item
|
| 1013 |
+
const item = order.items.find(
|
| 1014 |
+
(it) => it.product && it.product._id.toString() === productId,
|
| 1015 |
+
);
|
| 1016 |
+
|
| 1017 |
+
if (!item) {
|
| 1018 |
+
return res.status(404).json({
|
| 1019 |
+
status: 'fail',
|
| 1020 |
+
message: 'Product not found in this order',
|
| 1021 |
+
});
|
| 1022 |
+
}
|
| 1023 |
+
|
| 1024 |
+
const oldQuantity = item.quantity;
|
| 1025 |
+
const quantityDiff = quantity - oldQuantity;
|
| 1026 |
+
|
| 1027 |
+
// Update product stock
|
| 1028 |
+
if (quantityDiff !== 0) {
|
| 1029 |
+
const product = await Product.findById(productId);
|
| 1030 |
+
if (product) {
|
| 1031 |
+
// If increasing quantity, check if there's enough stock
|
| 1032 |
+
if (quantityDiff > 0 && product.stock < quantityDiff) {
|
| 1033 |
+
return res.status(400).json({
|
| 1034 |
+
status: 'fail',
|
| 1035 |
+
message: `Insufficient stock for ${product.nameAr || product.nameEn}. Available: ${product.stock}`,
|
| 1036 |
+
});
|
| 1037 |
+
}
|
| 1038 |
+
product.stock -= quantityDiff;
|
| 1039 |
+
await product.save();
|
| 1040 |
+
}
|
| 1041 |
+
}
|
| 1042 |
+
|
| 1043 |
+
// Update item quantity
|
| 1044 |
+
item.quantity = quantity;
|
| 1045 |
+
|
| 1046 |
+
// Recalculate totals
|
| 1047 |
+
let newSubtotal = 0;
|
| 1048 |
+
order.items.forEach((it) => {
|
| 1049 |
+
newSubtotal += it.unitPrice * it.quantity;
|
| 1050 |
+
});
|
| 1051 |
+
|
| 1052 |
+
// Handle discount recalculation if there was a promo code
|
| 1053 |
+
if (order.promo) {
|
| 1054 |
+
const promo = await Promo.findById(order.promo);
|
| 1055 |
+
if (promo) {
|
| 1056 |
+
const newDiscount = computeDiscountAmount(
|
| 1057 |
+
promo.type,
|
| 1058 |
+
promo.value,
|
| 1059 |
+
newSubtotal,
|
| 1060 |
+
);
|
| 1061 |
+
order.discountAmount = newDiscount;
|
| 1062 |
+
|
| 1063 |
+
if (promo.type === 'shipping') {
|
| 1064 |
+
order.totalPrice = newSubtotal;
|
| 1065 |
+
} else {
|
| 1066 |
+
order.totalPrice =
|
| 1067 |
+
Math.max(0, newSubtotal - newDiscount) + order.shippingPrice;
|
| 1068 |
+
}
|
| 1069 |
+
} else {
|
| 1070 |
+
// Promo not found, keep old shipping logic but update total
|
| 1071 |
+
order.totalPrice =
|
| 1072 |
+
newSubtotal + order.shippingPrice - order.discountAmount;
|
| 1073 |
+
}
|
| 1074 |
+
} else {
|
| 1075 |
+
order.totalPrice =
|
| 1076 |
+
newSubtotal + order.shippingPrice - order.discountAmount;
|
| 1077 |
+
}
|
| 1078 |
+
|
| 1079 |
+
await order.save();
|
| 1080 |
+
|
| 1081 |
+
emitOrderUpdate(order, 'order_quantity_updated');
|
| 1082 |
+
|
| 1083 |
+
res.status(200).json({
|
| 1084 |
+
status: 'success',
|
| 1085 |
+
data: { order },
|
| 1086 |
+
});
|
| 1087 |
+
|
| 1088 |
+
// Sync to Google Sheet (live update)
|
| 1089 |
+
syncOrderWithSheet(order).catch(() => {});
|
| 1090 |
+
} catch (err) {
|
| 1091 |
+
res.status(500).json({ status: 'error', message: err.message });
|
| 1092 |
+
}
|
| 1093 |
+
};
|
| 1094 |
+
|
| 1095 |
+
exports.addOrderItem = async (req, res) => {
|
| 1096 |
+
try {
|
| 1097 |
+
const { id: orderId } = req.params;
|
| 1098 |
+
const { productId, quantity } = req.body;
|
| 1099 |
+
|
| 1100 |
+
if (!quantity || quantity < 1) {
|
| 1101 |
+
return res.status(400).json({
|
| 1102 |
+
status: 'fail',
|
| 1103 |
+
message: 'Quantity must be at least 1',
|
| 1104 |
+
});
|
| 1105 |
+
}
|
| 1106 |
+
|
| 1107 |
+
const order = await Order.findById(orderId).populate('items.product');
|
| 1108 |
+
if (!order) {
|
| 1109 |
+
return res.status(404).json({
|
| 1110 |
+
status: 'fail',
|
| 1111 |
+
message: 'Order not found',
|
| 1112 |
+
});
|
| 1113 |
+
}
|
| 1114 |
+
|
| 1115 |
+
const product = await Product.findById(productId);
|
| 1116 |
+
if (!product) {
|
| 1117 |
+
return res.status(404).json({
|
| 1118 |
+
status: 'fail',
|
| 1119 |
+
message: 'Product not found',
|
| 1120 |
+
});
|
| 1121 |
+
}
|
| 1122 |
+
|
| 1123 |
+
if (product.stock < quantity) {
|
| 1124 |
+
return res.status(400).json({
|
| 1125 |
+
status: 'fail',
|
| 1126 |
+
message: `Insufficient stock for ${product.nameAr || product.nameEn}. Available: ${product.stock}`,
|
| 1127 |
+
});
|
| 1128 |
+
}
|
| 1129 |
+
|
| 1130 |
+
// Authorization check
|
| 1131 |
+
const isAdmin = req.user.role === 'admin';
|
| 1132 |
+
const isEmployee =
|
| 1133 |
+
req.user.role === 'employee' &&
|
| 1134 |
+
req.user.permissions.includes(PERMISSIONS.MANAGE_ORDERS);
|
| 1135 |
+
|
| 1136 |
+
if (!isAdmin && !isEmployee) {
|
| 1137 |
+
return res.status(403).json({
|
| 1138 |
+
status: 'fail',
|
| 1139 |
+
message: 'Not authorized to add items to orders',
|
| 1140 |
+
});
|
| 1141 |
+
}
|
| 1142 |
+
|
| 1143 |
+
// Check if product already exists in order
|
| 1144 |
+
const existingItem = order.items.find(
|
| 1145 |
+
(item) => item.product && item.product._id.toString() === productId,
|
| 1146 |
+
);
|
| 1147 |
+
|
| 1148 |
+
if (existingItem) {
|
| 1149 |
+
existingItem.quantity += quantity;
|
| 1150 |
+
existingItem.fulfillmentStatus = 'pending';
|
| 1151 |
+
} else {
|
| 1152 |
+
order.items.push({
|
| 1153 |
+
product: productId,
|
| 1154 |
+
name: product.nameAr || product.nameEn,
|
| 1155 |
+
quantity,
|
| 1156 |
+
unitPrice:
|
| 1157 |
+
product.salePrice && product.salePrice < product.price
|
| 1158 |
+
? product.salePrice
|
| 1159 |
+
: product.price,
|
| 1160 |
+
});
|
| 1161 |
+
}
|
| 1162 |
+
|
| 1163 |
+
// Deduct stock
|
| 1164 |
+
product.stock -= quantity;
|
| 1165 |
+
await product.save();
|
| 1166 |
+
|
| 1167 |
+
// Recalculate totals
|
| 1168 |
+
let newSubtotal = 0;
|
| 1169 |
+
order.items.forEach((it) => {
|
| 1170 |
+
newSubtotal += it.unitPrice * it.quantity;
|
| 1171 |
+
});
|
| 1172 |
+
|
| 1173 |
+
if (order.promo) {
|
| 1174 |
+
const promo = await Promo.findById(order.promo);
|
| 1175 |
+
if (promo) {
|
| 1176 |
+
const newDiscount = computeDiscountAmount(
|
| 1177 |
+
promo.type,
|
| 1178 |
+
promo.value,
|
| 1179 |
+
newSubtotal,
|
| 1180 |
+
);
|
| 1181 |
+
order.discountAmount = newDiscount;
|
| 1182 |
+
|
| 1183 |
+
if (promo.type === 'shipping') {
|
| 1184 |
+
order.totalPrice = newSubtotal;
|
| 1185 |
+
} else {
|
| 1186 |
+
order.totalPrice =
|
| 1187 |
+
Math.max(0, newSubtotal - newDiscount) + order.shippingPrice;
|
| 1188 |
+
}
|
| 1189 |
+
} else {
|
| 1190 |
+
order.totalPrice =
|
| 1191 |
+
newSubtotal + order.shippingPrice - order.discountAmount;
|
| 1192 |
+
}
|
| 1193 |
+
} else {
|
| 1194 |
+
order.totalPrice =
|
| 1195 |
+
newSubtotal + order.shippingPrice - order.discountAmount;
|
| 1196 |
+
}
|
| 1197 |
+
|
| 1198 |
+
await order.save();
|
| 1199 |
+
|
| 1200 |
+
emitOrderUpdate(order, 'order_item_added');
|
| 1201 |
+
|
| 1202 |
+
res.status(200).json({
|
| 1203 |
+
status: 'success',
|
| 1204 |
+
data: { order },
|
| 1205 |
+
});
|
| 1206 |
+
|
| 1207 |
+
// Sync to Google Sheet (live update)
|
| 1208 |
+
syncOrderWithSheet(order).catch(() => {});
|
| 1209 |
+
} catch (err) {
|
| 1210 |
+
res.status(500).json({ status: 'error', message: err.message });
|
| 1211 |
+
}
|
| 1212 |
+
};
|
| 1213 |
+
|
| 1214 |
+
exports.removeOrderItem = async (req, res) => {
|
| 1215 |
+
try {
|
| 1216 |
+
const { orderId, productId } = req.params;
|
| 1217 |
+
|
| 1218 |
+
const order = await Order.findById(orderId);
|
| 1219 |
+
|
| 1220 |
+
if (!order) {
|
| 1221 |
+
return res.status(404).json({
|
| 1222 |
+
status: 'fail',
|
| 1223 |
+
message: 'Order not found',
|
| 1224 |
+
});
|
| 1225 |
+
}
|
| 1226 |
+
|
| 1227 |
+
// Authorization check
|
| 1228 |
+
const isAdmin = req.user.role === 'admin';
|
| 1229 |
+
const isEmployee =
|
| 1230 |
+
req.user.role === 'employee' &&
|
| 1231 |
+
req.user.permissions.includes(PERMISSIONS.MANAGE_ORDERS);
|
| 1232 |
+
|
| 1233 |
+
if (!isAdmin && !isEmployee) {
|
| 1234 |
+
return res.status(403).json({
|
| 1235 |
+
status: 'fail',
|
| 1236 |
+
message: 'Not authorized to remove items from orders',
|
| 1237 |
+
});
|
| 1238 |
+
}
|
| 1239 |
+
|
| 1240 |
+
// Find the item index
|
| 1241 |
+
const itemIndex = order.items.findIndex(
|
| 1242 |
+
(it) => it.product && it.product.toString() === productId,
|
| 1243 |
+
);
|
| 1244 |
+
|
| 1245 |
+
if (itemIndex === -1) {
|
| 1246 |
+
return res.status(404).json({
|
| 1247 |
+
status: 'fail',
|
| 1248 |
+
message: 'Product not found in this order',
|
| 1249 |
+
});
|
| 1250 |
+
}
|
| 1251 |
+
|
| 1252 |
+
// Prevent removing the last item (order must have at least one item)
|
| 1253 |
+
if (order.items.length <= 1) {
|
| 1254 |
+
return res.status(400).json({
|
| 1255 |
+
status: 'fail',
|
| 1256 |
+
message: 'Cannot remove the last item. Use "Delete Order" instead.',
|
| 1257 |
+
});
|
| 1258 |
+
}
|
| 1259 |
+
|
| 1260 |
+
const itemToRemove = order.items[itemIndex];
|
| 1261 |
+
|
| 1262 |
+
// Restore stock
|
| 1263 |
+
const product = await Product.findById(productId);
|
| 1264 |
+
if (product) {
|
| 1265 |
+
product.stock += itemToRemove.quantity;
|
| 1266 |
+
await product.save();
|
| 1267 |
+
}
|
| 1268 |
+
|
| 1269 |
+
// Remove the item
|
| 1270 |
+
order.items.splice(itemIndex, 1);
|
| 1271 |
+
|
| 1272 |
+
// Recalculate totals
|
| 1273 |
+
let newSubtotal = 0;
|
| 1274 |
+
order.items.forEach((it) => {
|
| 1275 |
+
newSubtotal += it.unitPrice * it.quantity;
|
| 1276 |
+
});
|
| 1277 |
+
|
| 1278 |
+
if (order.promo) {
|
| 1279 |
+
const promo = await Promo.findById(order.promo);
|
| 1280 |
+
if (promo) {
|
| 1281 |
+
const newDiscount = computeDiscountAmount(
|
| 1282 |
+
promo.type,
|
| 1283 |
+
promo.value,
|
| 1284 |
+
newSubtotal,
|
| 1285 |
+
);
|
| 1286 |
+
order.discountAmount = newDiscount;
|
| 1287 |
+
|
| 1288 |
+
if (promo.type === 'shipping') {
|
| 1289 |
+
order.totalPrice = newSubtotal;
|
| 1290 |
+
} else {
|
| 1291 |
+
order.totalPrice =
|
| 1292 |
+
Math.max(0, newSubtotal - newDiscount) + order.shippingPrice;
|
| 1293 |
+
}
|
| 1294 |
+
} else {
|
| 1295 |
+
order.totalPrice =
|
| 1296 |
+
newSubtotal + order.shippingPrice - order.discountAmount;
|
| 1297 |
+
}
|
| 1298 |
+
} else {
|
| 1299 |
+
order.totalPrice =
|
| 1300 |
+
newSubtotal + order.shippingPrice - order.discountAmount;
|
| 1301 |
+
}
|
| 1302 |
+
|
| 1303 |
+
await order.save();
|
| 1304 |
+
|
| 1305 |
+
emitOrderUpdate(order, 'order_item_removed');
|
| 1306 |
+
|
| 1307 |
+
res.status(200).json({
|
| 1308 |
+
status: 'success',
|
| 1309 |
+
data: { order },
|
| 1310 |
+
});
|
| 1311 |
+
|
| 1312 |
+
// Sync to Google Sheet (live update)
|
| 1313 |
+
syncOrderWithSheet(order).catch(() => {});
|
| 1314 |
+
} catch (err) {
|
| 1315 |
+
res.status(500).json({ status: 'error', message: err.message });
|
| 1316 |
+
}
|
| 1317 |
+
};
|
controllers/paymentController.js
ADDED
|
@@ -0,0 +1,516 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const Order = require('../models/orderModel');
|
| 2 |
+
const {
|
| 3 |
+
initiatePayment: initiatePaymobPayment,
|
| 4 |
+
verifyPayment: verifyPaymobPayment,
|
| 5 |
+
refundTransaction: refundPaymobTransaction,
|
| 6 |
+
} = require('../utils/paymobService');
|
| 7 |
+
const { sendOrderConfirmationEmail } = require('../utils/emailService');
|
| 8 |
+
const {
|
| 9 |
+
notifyOrderCreated,
|
| 10 |
+
notifyAdminsNewOrder,
|
| 11 |
+
notifyVendorNewOrder,
|
| 12 |
+
emitOrderUpdate,
|
| 13 |
+
} = require('../utils/notificationService');
|
| 14 |
+
const { syncOrderWithSheet } = require('../utils/googleSheetsService');
|
| 15 |
+
|
| 16 |
+
/**
|
| 17 |
+
* Initiate Paymob payment for an order
|
| 18 |
+
* This should be called after creating an order with payment method 'visa'
|
| 19 |
+
*/
|
| 20 |
+
exports.initiatePayment = async (req, res) => {
|
| 21 |
+
try {
|
| 22 |
+
const { orderId, frontendUrl } = req.body;
|
| 23 |
+
|
| 24 |
+
if (!orderId) {
|
| 25 |
+
return res.status(400).json({
|
| 26 |
+
status: 'fail',
|
| 27 |
+
message: 'Order ID is required',
|
| 28 |
+
});
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
const order = await Order.findById(orderId).populate('items.product');
|
| 32 |
+
|
| 33 |
+
if (!order) {
|
| 34 |
+
return res.status(404).json({
|
| 35 |
+
status: 'fail',
|
| 36 |
+
message: 'Order not found',
|
| 37 |
+
});
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
// Verify that the order belongs to the user (if not admin)
|
| 41 |
+
if (req.user && order.user && String(order.user) !== String(req.user._id)) {
|
| 42 |
+
return res.status(403).json({
|
| 43 |
+
status: 'fail',
|
| 44 |
+
message: 'Not authorized to access this order',
|
| 45 |
+
});
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
// Check if payment method is visa
|
| 49 |
+
if (order.payment.method !== 'visa') {
|
| 50 |
+
return res.status(400).json({
|
| 51 |
+
status: 'fail',
|
| 52 |
+
message: 'This order does not require online payment',
|
| 53 |
+
});
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
// Check if already paid
|
| 57 |
+
if (order.payment.status === 'paid') {
|
| 58 |
+
return res.status(400).json({
|
| 59 |
+
status: 'fail',
|
| 60 |
+
message: 'This order has already been paid',
|
| 61 |
+
});
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
// Prepare billing data
|
| 65 |
+
const nameParts = order.name.split(' ');
|
| 66 |
+
const billingData = {
|
| 67 |
+
firstName: nameParts[0] || order.name,
|
| 68 |
+
lastName: nameParts.slice(1).join(' ') || nameParts[0],
|
| 69 |
+
email: req.user ? req.user.email : 'guest@samoulla.com',
|
| 70 |
+
phone: order.mobile,
|
| 71 |
+
street: order.address.street,
|
| 72 |
+
city: order.address.city,
|
| 73 |
+
state: order.address.governorate,
|
| 74 |
+
country: 'EG',
|
| 75 |
+
apartment: 'NA',
|
| 76 |
+
floor: 'NA',
|
| 77 |
+
building: 'NA',
|
| 78 |
+
postalCode: 'NA',
|
| 79 |
+
};
|
| 80 |
+
|
| 81 |
+
// Prepare order data for Paymob
|
| 82 |
+
const orderData = {
|
| 83 |
+
amount: order.totalPrice,
|
| 84 |
+
items: order.items.map((item) => ({
|
| 85 |
+
name: item.name,
|
| 86 |
+
quantity: item.quantity,
|
| 87 |
+
unitPrice: item.unitPrice,
|
| 88 |
+
description: item.name,
|
| 89 |
+
})),
|
| 90 |
+
billingData,
|
| 91 |
+
};
|
| 92 |
+
|
| 93 |
+
// Initiate payment with Paymob
|
| 94 |
+
const paymentResult = await initiatePaymobPayment(orderData);
|
| 95 |
+
|
| 96 |
+
if (!paymentResult.success) {
|
| 97 |
+
return res.status(500).json({
|
| 98 |
+
status: 'error',
|
| 99 |
+
message: 'Failed to initiate payment',
|
| 100 |
+
error: paymentResult.error,
|
| 101 |
+
});
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
// Update order with Paymob data
|
| 105 |
+
order.payment.paymobOrderId = paymentResult.paymobOrderId;
|
| 106 |
+
order.payment.paymentKey = paymentResult.paymentKey;
|
| 107 |
+
if (frontendUrl) {
|
| 108 |
+
order.payment.frontendUrl = frontendUrl;
|
| 109 |
+
}
|
| 110 |
+
await order.save();
|
| 111 |
+
|
| 112 |
+
res.status(200).json({
|
| 113 |
+
status: 'success',
|
| 114 |
+
data: {
|
| 115 |
+
paymentKey: paymentResult.paymentKey,
|
| 116 |
+
iframeUrl: paymentResult.iframeUrl,
|
| 117 |
+
paymobOrderId: paymentResult.paymobOrderId,
|
| 118 |
+
},
|
| 119 |
+
});
|
| 120 |
+
} catch (error) {
|
| 121 |
+
console.error('Payment initiation error:', error);
|
| 122 |
+
res.status(500).json({
|
| 123 |
+
status: 'error',
|
| 124 |
+
message: error.message,
|
| 125 |
+
});
|
| 126 |
+
}
|
| 127 |
+
};
|
| 128 |
+
|
| 129 |
+
const { restoreOrderStock } = require('../utils/orderUtils');
|
| 130 |
+
const Promo = require('../models/promoCodeModel');
|
| 131 |
+
|
| 132 |
+
/**
|
| 133 |
+
* Paymob callback handler
|
| 134 |
+
* This endpoint receives payment status updates from Paymob
|
| 135 |
+
*/
|
| 136 |
+
exports.paymobCallback = async (req, res) => {
|
| 137 |
+
let order;
|
| 138 |
+
try {
|
| 139 |
+
// Combine body and query for consistent data access
|
| 140 |
+
const transactionData = { ...req.query, ...req.body };
|
| 141 |
+
|
| 142 |
+
console.log(
|
| 143 |
+
'Paymob callback received:',
|
| 144 |
+
JSON.stringify(transactionData, null, 2),
|
| 145 |
+
);
|
| 146 |
+
|
| 147 |
+
// Verify payment using HMAC
|
| 148 |
+
const verificationResult = await verifyPaymobPayment(transactionData);
|
| 149 |
+
|
| 150 |
+
// TRY TO FIND ORDER EARLY (to get frontendUrl for redirects)
|
| 151 |
+
if (verificationResult.orderId) {
|
| 152 |
+
order = await Order.findOne({
|
| 153 |
+
'payment.paymobOrderId': String(verificationResult.orderId),
|
| 154 |
+
}).populate('user', 'name email');
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
const fallbackUrl = process.env.FRONTEND_URL || 'http://localhost:5173';
|
| 158 |
+
const frontendUrl =
|
| 159 |
+
(order && order.payment && order.payment.frontendUrl) || fallbackUrl;
|
| 160 |
+
|
| 161 |
+
// 1. VERIFICATION CHECK
|
| 162 |
+
if (!verificationResult.hmacVerified) {
|
| 163 |
+
console.error('Payment verification failed: Invalid HMAC signature');
|
| 164 |
+
if (req.method === 'GET') {
|
| 165 |
+
return res.redirect(
|
| 166 |
+
`${frontendUrl}/checkout?error=verification_failed`,
|
| 167 |
+
);
|
| 168 |
+
}
|
| 169 |
+
return res
|
| 170 |
+
.status(200)
|
| 171 |
+
.json({ message: 'Callback received but verification failed' });
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
// 2. FIND AND UPDATE ORDER (If not already found)
|
| 175 |
+
if (!order) {
|
| 176 |
+
console.error(
|
| 177 |
+
'Order not found for Paymob order ID:',
|
| 178 |
+
verificationResult.orderId,
|
| 179 |
+
);
|
| 180 |
+
if (req.method === 'GET') {
|
| 181 |
+
// If it's a redirect and order is gone (maybe deleted by POST already),
|
| 182 |
+
// We still want to go back to checkout with a status if possible
|
| 183 |
+
const status = verificationResult.success ? 'success' : 'failed';
|
| 184 |
+
if (status === 'failed') {
|
| 185 |
+
return res.redirect(`${frontendUrl}/checkout?status=failed`);
|
| 186 |
+
}
|
| 187 |
+
return res.redirect(`${frontendUrl}/checkout?error=order_not_found`);
|
| 188 |
+
}
|
| 189 |
+
return res.status(200).json({ message: 'Order not found' });
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
// IDEMPOTENCY CHECK: Skip if already processed (paid or explicitly cancelled)
|
| 193 |
+
const isAlreadyPaid = order.payment.status === 'paid';
|
| 194 |
+
const isAlreadyCancelled = order.orderStatus === 'cancelled';
|
| 195 |
+
|
| 196 |
+
if (!isAlreadyPaid && !isAlreadyCancelled) {
|
| 197 |
+
if (verificationResult.success) {
|
| 198 |
+
// SUCCESSFUL TRANSACTION
|
| 199 |
+
order.payment.status = 'paid';
|
| 200 |
+
order.payment.paidAt = new Date();
|
| 201 |
+
order.payment.paymobTransactionId = String(
|
| 202 |
+
verificationResult.transactionId,
|
| 203 |
+
);
|
| 204 |
+
|
| 205 |
+
// Ensure orderStatus is 'created' or 'processing' if paid
|
| 206 |
+
if (order.orderStatus === 'cancelled') order.orderStatus = 'created';
|
| 207 |
+
|
| 208 |
+
await order.save();
|
| 209 |
+
|
| 210 |
+
// Send confirmation email and notifications
|
| 211 |
+
try {
|
| 212 |
+
// Notify the user
|
| 213 |
+
if (order.user) {
|
| 214 |
+
await notifyOrderCreated(order, order.user._id || order.user);
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
// Notify admins
|
| 218 |
+
await notifyAdminsNewOrder(order);
|
| 219 |
+
|
| 220 |
+
// Notify vendors
|
| 221 |
+
const uniqueProviderIds = new Set();
|
| 222 |
+
// We need items available here. Let's populate them if needed or use the existing ones.
|
| 223 |
+
// The order already has items array but products might not be populated with provider.
|
| 224 |
+
const orderWithProducts = await Order.findById(order._id).populate(
|
| 225 |
+
'items.product',
|
| 226 |
+
);
|
| 227 |
+
orderWithProducts.items.forEach((item) => {
|
| 228 |
+
if (item.product && item.product.provider) {
|
| 229 |
+
uniqueProviderIds.add(item.product.provider.toString());
|
| 230 |
+
}
|
| 231 |
+
});
|
| 232 |
+
|
| 233 |
+
for (const pId of uniqueProviderIds) {
|
| 234 |
+
await notifyVendorNewOrder(order, pId);
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
// Send Email
|
| 238 |
+
if (order.user) {
|
| 239 |
+
// Populate for email (fully)
|
| 240 |
+
const populatedOrderForEmail = await Order.findById(
|
| 241 |
+
order._id,
|
| 242 |
+
).populate({
|
| 243 |
+
path: 'items.product',
|
| 244 |
+
select: 'nameAr price imageCover',
|
| 245 |
+
populate: { path: 'provider', select: 'storeName' },
|
| 246 |
+
});
|
| 247 |
+
|
| 248 |
+
const emailOrderData = {
|
| 249 |
+
orderNumber: order._id.toString().slice(-8).toUpperCase(),
|
| 250 |
+
items: populatedOrderForEmail.items.map((item) => ({
|
| 251 |
+
product: {
|
| 252 |
+
nameAr: (item.product && item.product.nameAr) || item.name,
|
| 253 |
+
imageCover: (item.product && item.product.imageCover) || null,
|
| 254 |
+
provider: (item.product && item.product.provider) || null,
|
| 255 |
+
},
|
| 256 |
+
quantity: item.quantity,
|
| 257 |
+
price: item.unitPrice,
|
| 258 |
+
})),
|
| 259 |
+
subtotal:
|
| 260 |
+
order.totalPrice -
|
| 261 |
+
(order.shippingPrice || 0) +
|
| 262 |
+
(order.discountAmount || 0),
|
| 263 |
+
discount: order.discountAmount || 0,
|
| 264 |
+
shippingCost: order.shippingPrice || 0,
|
| 265 |
+
total: order.totalPrice,
|
| 266 |
+
paymentMethod: order.payment.method,
|
| 267 |
+
shippingAddress: {
|
| 268 |
+
street: order.address.street,
|
| 269 |
+
city: order.address.city,
|
| 270 |
+
governorate: order.address.governorate,
|
| 271 |
+
phone: order.mobile,
|
| 272 |
+
},
|
| 273 |
+
};
|
| 274 |
+
|
| 275 |
+
await sendOrderConfirmationEmail(emailOrderData, order.user);
|
| 276 |
+
}
|
| 277 |
+
} catch (notifErr) {
|
| 278 |
+
console.error('Notification/Email error:', notifErr);
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
emitOrderUpdate(order, 'payment_completed');
|
| 282 |
+
console.log(`✅ Payment successful for order ${order._id}`);
|
| 283 |
+
|
| 284 |
+
// Sync to Google Sheet (live update)
|
| 285 |
+
syncOrderWithSheet(order).catch(() => {});
|
| 286 |
+
} else {
|
| 287 |
+
// FAILED TRANSACTION
|
| 288 |
+
// Per user request: Don't place (keep) the order if payment failed.
|
| 289 |
+
// We delete it instead of marking as cancelled.
|
| 290 |
+
|
| 291 |
+
// 1. RESTORE STOCK
|
| 292 |
+
try {
|
| 293 |
+
await restoreOrderStock(order.items);
|
| 294 |
+
console.log(
|
| 295 |
+
`📦 Stock restored for deleted (previously failed) order ${order._id}`,
|
| 296 |
+
);
|
| 297 |
+
} catch (stockErr) {
|
| 298 |
+
console.error('Failed to restore stock:', stockErr);
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
// 2. RESTORE PROMO (if any)
|
| 302 |
+
if (order.promo) {
|
| 303 |
+
try {
|
| 304 |
+
await Promo.findByIdAndUpdate(order.promo, {
|
| 305 |
+
$inc: { usedCount: -1 },
|
| 306 |
+
});
|
| 307 |
+
console.log(`🏷️ Promo usage rolled back for order ${order._id}`);
|
| 308 |
+
} catch (promoErr) {
|
| 309 |
+
console.error('Failed to restore promo usage:', promoErr);
|
| 310 |
+
}
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
// 3. EMIT FAILURE EVENT (before delete)
|
| 314 |
+
emitOrderUpdate(order, 'payment_failed');
|
| 315 |
+
|
| 316 |
+
// 4. DELETE THE ORDER
|
| 317 |
+
const orderId = order._id;
|
| 318 |
+
await Order.findByIdAndDelete(orderId);
|
| 319 |
+
console.log(`🗑️ Failed order ${orderId} deleted from database`);
|
| 320 |
+
}
|
| 321 |
+
} else {
|
| 322 |
+
console.log(
|
| 323 |
+
`ℹ️ Order ${order._id} already processed. Status: ${order.payment.status}, OrderStatus: ${order.orderStatus}`,
|
| 324 |
+
);
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
// 3. FINAL RESPONSE
|
| 328 |
+
if (req.method === 'GET') {
|
| 329 |
+
const status = verificationResult.success ? 'success' : 'failed';
|
| 330 |
+
console.log(
|
| 331 |
+
`Redirecting user for order ${order._id} with status ${status} to ${frontendUrl}`,
|
| 332 |
+
);
|
| 333 |
+
|
| 334 |
+
if (status === 'success') {
|
| 335 |
+
return res.redirect(
|
| 336 |
+
`${frontendUrl}/order-confirmation/${order._id}?status=success`,
|
| 337 |
+
);
|
| 338 |
+
}
|
| 339 |
+
return res.redirect(
|
| 340 |
+
`${frontendUrl}/checkout?status=failed&orderId=${order._id}`,
|
| 341 |
+
);
|
| 342 |
+
}
|
| 343 |
+
|
| 344 |
+
res.status(200).json({
|
| 345 |
+
status: 'success',
|
| 346 |
+
message: 'Callback processed successfully',
|
| 347 |
+
});
|
| 348 |
+
} catch (error) {
|
| 349 |
+
console.error('Callback processing error:', error);
|
| 350 |
+
if (req.method === 'GET') {
|
| 351 |
+
const fallbackUrlCatch =
|
| 352 |
+
process.env.FRONTEND_URL || 'http://localhost:5173';
|
| 353 |
+
const frontendUrlCatch =
|
| 354 |
+
(order && order.payment && order.payment.frontendUrl) ||
|
| 355 |
+
fallbackUrlCatch;
|
| 356 |
+
return res.redirect(
|
| 357 |
+
`${frontendUrlCatch}/checkout?error=internal_server_error`,
|
| 358 |
+
);
|
| 359 |
+
}
|
| 360 |
+
res
|
| 361 |
+
.status(200)
|
| 362 |
+
.json({ message: 'Callback received but processing failed' });
|
| 363 |
+
}
|
| 364 |
+
};
|
| 365 |
+
|
| 366 |
+
/**
|
| 367 |
+
* Check payment status for an order
|
| 368 |
+
*/
|
| 369 |
+
exports.checkPaymentStatus = async (req, res) => {
|
| 370 |
+
try {
|
| 371 |
+
const { orderId } = req.params;
|
| 372 |
+
|
| 373 |
+
const order = await Order.findById(orderId);
|
| 374 |
+
|
| 375 |
+
if (!order) {
|
| 376 |
+
return res.status(404).json({
|
| 377 |
+
status: 'fail',
|
| 378 |
+
message: 'Order not found',
|
| 379 |
+
});
|
| 380 |
+
}
|
| 381 |
+
|
| 382 |
+
// Verify that the order belongs to the user (if not admin)
|
| 383 |
+
if (req.user && order.user && String(order.user) !== String(req.user._id)) {
|
| 384 |
+
return res.status(403).json({
|
| 385 |
+
status: 'fail',
|
| 386 |
+
message: 'Not authorized to access this order',
|
| 387 |
+
});
|
| 388 |
+
}
|
| 389 |
+
|
| 390 |
+
res.status(200).json({
|
| 391 |
+
status: 'success',
|
| 392 |
+
data: {
|
| 393 |
+
paymentStatus: order.payment.status,
|
| 394 |
+
paymentMethod: order.payment.method,
|
| 395 |
+
paidAt: order.payment.paidAt,
|
| 396 |
+
transactionId: order.payment.paymobTransactionId,
|
| 397 |
+
},
|
| 398 |
+
});
|
| 399 |
+
} catch (error) {
|
| 400 |
+
console.error('Payment status check error:', error);
|
| 401 |
+
res.status(500).json({
|
| 402 |
+
status: 'error',
|
| 403 |
+
message: error.message,
|
| 404 |
+
});
|
| 405 |
+
}
|
| 406 |
+
};
|
| 407 |
+
|
| 408 |
+
/**
|
| 409 |
+
* Refund a payment (Admin only)
|
| 410 |
+
*/
|
| 411 |
+
exports.refundPayment = async (req, res) => {
|
| 412 |
+
try {
|
| 413 |
+
const { orderId } = req.params;
|
| 414 |
+
const { amount } = req.body;
|
| 415 |
+
|
| 416 |
+
const order = await Order.findById(orderId);
|
| 417 |
+
|
| 418 |
+
if (!order) {
|
| 419 |
+
return res.status(404).json({
|
| 420 |
+
status: 'fail',
|
| 421 |
+
message: 'Order not found',
|
| 422 |
+
});
|
| 423 |
+
}
|
| 424 |
+
|
| 425 |
+
// Check if order was paid
|
| 426 |
+
if (order.payment.status !== 'paid') {
|
| 427 |
+
return res.status(400).json({
|
| 428 |
+
status: 'fail',
|
| 429 |
+
message: 'Cannot refund an unpaid order',
|
| 430 |
+
});
|
| 431 |
+
}
|
| 432 |
+
|
| 433 |
+
// Check if transaction ID exists
|
| 434 |
+
if (!order.payment.paymobTransactionId) {
|
| 435 |
+
return res.status(400).json({
|
| 436 |
+
status: 'fail',
|
| 437 |
+
message: 'No Paymob transaction found for this order',
|
| 438 |
+
});
|
| 439 |
+
}
|
| 440 |
+
|
| 441 |
+
// Determine refund amount
|
| 442 |
+
const refundAmount = amount || order.totalPrice;
|
| 443 |
+
|
| 444 |
+
if (refundAmount > order.totalPrice) {
|
| 445 |
+
return res.status(400).json({
|
| 446 |
+
status: 'fail',
|
| 447 |
+
message: 'Refund amount cannot exceed order total',
|
| 448 |
+
});
|
| 449 |
+
}
|
| 450 |
+
|
| 451 |
+
// Process refund with Paymob
|
| 452 |
+
const refundResult = await refundPaymobTransaction(
|
| 453 |
+
order.payment.paymobTransactionId,
|
| 454 |
+
refundAmount,
|
| 455 |
+
);
|
| 456 |
+
|
| 457 |
+
if (!refundResult.success) {
|
| 458 |
+
return res.status(500).json({
|
| 459 |
+
status: 'error',
|
| 460 |
+
message: 'Failed to process refund',
|
| 461 |
+
error: refundResult.error,
|
| 462 |
+
});
|
| 463 |
+
}
|
| 464 |
+
|
| 465 |
+
// Update order
|
| 466 |
+
order.payment.refundedAt = new Date();
|
| 467 |
+
order.payment.refundAmount = refundAmount;
|
| 468 |
+
order.payment.status = 'failed'; // Mark as failed after refund
|
| 469 |
+
await order.save();
|
| 470 |
+
|
| 471 |
+
// Broadcast order update
|
| 472 |
+
emitOrderUpdate(order, 'payment_refunded');
|
| 473 |
+
|
| 474 |
+
res.status(200).json({
|
| 475 |
+
status: 'success',
|
| 476 |
+
data: {
|
| 477 |
+
message: 'Refund processed successfully',
|
| 478 |
+
refundAmount,
|
| 479 |
+
order,
|
| 480 |
+
},
|
| 481 |
+
});
|
| 482 |
+
} catch (error) {
|
| 483 |
+
console.error('Refund processing error:', error);
|
| 484 |
+
res.status(500).json({
|
| 485 |
+
status: 'error',
|
| 486 |
+
message: error.message,
|
| 487 |
+
});
|
| 488 |
+
}
|
| 489 |
+
};
|
| 490 |
+
|
| 491 |
+
/**
|
| 492 |
+
* Paymob transaction processed callback (Alternative callback endpoint)
|
| 493 |
+
*/
|
| 494 |
+
exports.paymobTransactionCallback = async (req, res) => {
|
| 495 |
+
try {
|
| 496 |
+
// Extract data from query parameters (Paymob sends some data via GET)
|
| 497 |
+
const transactionData = {
|
| 498 |
+
...req.query,
|
| 499 |
+
...req.body,
|
| 500 |
+
};
|
| 501 |
+
|
| 502 |
+
console.log(
|
| 503 |
+
'Transaction callback received:',
|
| 504 |
+
JSON.stringify(transactionData, null, 2),
|
| 505 |
+
);
|
| 506 |
+
|
| 507 |
+
// Forward to main callback handler
|
| 508 |
+
req.body = transactionData;
|
| 509 |
+
return exports.paymobCallback(req, res);
|
| 510 |
+
} catch (error) {
|
| 511 |
+
console.error('Transaction callback error:', error);
|
| 512 |
+
res.status(200).json({ message: 'Callback received' });
|
| 513 |
+
}
|
| 514 |
+
};
|
| 515 |
+
|
| 516 |
+
module.exports = exports;
|
controllers/productController.js
ADDED
|
@@ -0,0 +1,2191 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const mongoose = require('mongoose');
|
| 2 |
+
const axios = require('axios');
|
| 3 |
+
const xlsx = require('xlsx');
|
| 4 |
+
const FormData = require('form-data');
|
| 5 |
+
const { cloudinary } = require('../config/cloudinary');
|
| 6 |
+
const Product = require('../models/productModel');
|
| 7 |
+
const Category = require('../models/categoryModel');
|
| 8 |
+
const Provider = require('../models/providerModel');
|
| 9 |
+
const Brand = require('../models/brandModel');
|
| 10 |
+
const User = require('../models/userModel');
|
| 11 |
+
const APIFeatures = require('../utils/apiFeatures');
|
| 12 |
+
const {
|
| 13 |
+
createSmartSearchRegex,
|
| 14 |
+
convertArabicNumerals,
|
| 15 |
+
} = require('../utils/arabicSearch');
|
| 16 |
+
const {
|
| 17 |
+
notifyPastBuyersProductBackInStock,
|
| 18 |
+
notifyStockSubscribers,
|
| 19 |
+
} = require('../utils/notificationService');
|
| 20 |
+
const {
|
| 21 |
+
extractVoiceEntities,
|
| 22 |
+
buildVoiceFilterQuery,
|
| 23 |
+
buildMongoVoiceQuery,
|
| 24 |
+
scoreProduct: scoreVoiceProduct,
|
| 25 |
+
buildSearchTextFromEntities,
|
| 26 |
+
} = require('../utils/voiceSearch');
|
| 27 |
+
|
| 28 |
+
// Paste your Cloudinary logo URL here to force it as import fallback cover.
|
| 29 |
+
// Example: https://res.cloudinary.com/<cloud-name>/image/upload/v1234567890/samoulla/system/logo-cover.png
|
| 30 |
+
const HARDCODED_IMPORT_LOGO_COVER =
|
| 31 |
+
'https://res.cloudinary.com/dm9ym99zh/image/upload/v1771470584/Asset_16_wfto7q.png';
|
| 32 |
+
|
| 33 |
+
const IMPORT_FALLBACK_COVER_IMAGE =
|
| 34 |
+
HARDCODED_IMPORT_LOGO_COVER ||
|
| 35 |
+
process.env.IMPORT_FALLBACK_COVER_IMAGE ||
|
| 36 |
+
'https://placehold.co/1000x1000/png?text=No+Image';
|
| 37 |
+
|
| 38 |
+
const isImageUrlReachable = async (url) => {
|
| 39 |
+
if (!url || typeof url !== 'string' || !url.startsWith('http')) return false;
|
| 40 |
+
|
| 41 |
+
try {
|
| 42 |
+
const response = await axios.head(url, {
|
| 43 |
+
timeout: 7000,
|
| 44 |
+
maxRedirects: 5,
|
| 45 |
+
validateStatus: () => true,
|
| 46 |
+
});
|
| 47 |
+
|
| 48 |
+
if (response.status === 405) {
|
| 49 |
+
const fallbackResponse = await axios.get(url, {
|
| 50 |
+
timeout: 7000,
|
| 51 |
+
maxRedirects: 5,
|
| 52 |
+
headers: { Range: 'bytes=0-0' },
|
| 53 |
+
validateStatus: () => true,
|
| 54 |
+
});
|
| 55 |
+
return fallbackResponse.status >= 200 && fallbackResponse.status < 400;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
return response.status >= 200 && response.status < 400;
|
| 59 |
+
} catch (_) {
|
| 60 |
+
return false;
|
| 61 |
+
}
|
| 62 |
+
};
|
| 63 |
+
|
| 64 |
+
// Helper to auto-upload external image URLs to Cloudinary during import
|
| 65 |
+
const uploadExternalImage = async (url, productId, isCover = true) => {
|
| 66 |
+
if (!url || typeof url !== 'string') return '';
|
| 67 |
+
|
| 68 |
+
// Skip non-URL values
|
| 69 |
+
if (!url.startsWith('http')) return '';
|
| 70 |
+
|
| 71 |
+
// Reuse URL only if it's already hosted on the current Cloudinary cloud.
|
| 72 |
+
// Legacy Cloudinary URLs (e.g., old project cloud) are re-hosted to avoid 404s.
|
| 73 |
+
const currentCloudName = process.env.CLOUDINARY_CLOUD_NAME;
|
| 74 |
+
let sourceCloudName = null;
|
| 75 |
+
|
| 76 |
+
try {
|
| 77 |
+
const parsed = new URL(url);
|
| 78 |
+
const match = parsed.hostname.match(/^res\.cloudinary\.com$/i)
|
| 79 |
+
? parsed.pathname.match(/^\/([^/]+)\//)
|
| 80 |
+
: null;
|
| 81 |
+
sourceCloudName = match ? match[1] : null;
|
| 82 |
+
|
| 83 |
+
const inTargetFolderRegex = new RegExp(
|
| 84 |
+
`/samoulla/products/${String(productId).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/`,
|
| 85 |
+
);
|
| 86 |
+
const isAlreadyInTargetFolder = inTargetFolderRegex.test(parsed.pathname);
|
| 87 |
+
|
| 88 |
+
if (sourceCloudName && sourceCloudName === currentCloudName) {
|
| 89 |
+
const reachable = await isImageUrlReachable(url);
|
| 90 |
+
if (!reachable) return '';
|
| 91 |
+
|
| 92 |
+
// Keep existing URL only when it's already inside the product _id folder.
|
| 93 |
+
// Otherwise, re-host to enforce folder consistency for this product.
|
| 94 |
+
if (isAlreadyInTargetFolder) return url;
|
| 95 |
+
}
|
| 96 |
+
} catch (_) {
|
| 97 |
+
return '';
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
try {
|
| 101 |
+
const prefix = isCover ? 'cover' : 'img';
|
| 102 |
+
const result = await cloudinary.uploader.upload(url, {
|
| 103 |
+
folder: `samoulla/products/${productId}`,
|
| 104 |
+
public_id: `${prefix}-${Date.now()}`,
|
| 105 |
+
transformation: [
|
| 106 |
+
{ width: 1000, height: 1000, crop: 'limit' },
|
| 107 |
+
{ quality: 'auto' },
|
| 108 |
+
{ fetch_format: 'auto' },
|
| 109 |
+
],
|
| 110 |
+
});
|
| 111 |
+
return result.secure_url;
|
| 112 |
+
} catch (err) {
|
| 113 |
+
console.error(`Auto-upload failed for ${url}:`, err.message);
|
| 114 |
+
return '';
|
| 115 |
+
}
|
| 116 |
+
};
|
| 117 |
+
|
| 118 |
+
// Helper to delete a single image from Cloudinary by its secure URL
|
| 119 |
+
const deleteCloudinaryImage = async (url) => {
|
| 120 |
+
if (!url || typeof url !== 'string' || !url.includes('cloudinary.com'))
|
| 121 |
+
return;
|
| 122 |
+
const currentCloudName = cloudinary.config().cloud_name;
|
| 123 |
+
if (!url.includes(`/${currentCloudName}/`)) return; // Not our cloud, don't touch it
|
| 124 |
+
try {
|
| 125 |
+
// Extract public_id: everything after /upload/(v<digits>/) and before the file extension
|
| 126 |
+
const match = url.match(/\/upload\/(?:v\d+\/)?(.+?)(?:\.[a-z0-9]+)?$/i);
|
| 127 |
+
if (!match) return;
|
| 128 |
+
const publicId = match[1];
|
| 129 |
+
await cloudinary.uploader.destroy(publicId);
|
| 130 |
+
} catch (err) {
|
| 131 |
+
console.error(`Failed to delete Cloudinary image ${url}:`, err.message);
|
| 132 |
+
}
|
| 133 |
+
};
|
| 134 |
+
|
| 135 |
+
// Helper to notify the AI service to reload its product cache
|
| 136 |
+
const triggerAiReload = async () => {
|
| 137 |
+
try {
|
| 138 |
+
const aiUrl = process.env.AI_SERVICE_URL || 'http://localhost:9001';
|
| 139 |
+
await axios.post(`${aiUrl}/api/reload-products`);
|
| 140 |
+
console.log('AI product cache reload triggered successfully');
|
| 141 |
+
} catch (err) {
|
| 142 |
+
// Fail silently in production, just log it
|
| 143 |
+
console.error('Failed to trigger AI product reload:', err.message);
|
| 144 |
+
}
|
| 145 |
+
};
|
| 146 |
+
|
| 147 |
+
// Get all products
|
| 148 |
+
exports.getAllProducts = async (req, res) => {
|
| 149 |
+
try {
|
| 150 |
+
// 1) Filter by Category (Hierarchy-aware)
|
| 151 |
+
if (req.query.category !== undefined) {
|
| 152 |
+
if (
|
| 153 |
+
req.query.category === 'جميع المنتجات' ||
|
| 154 |
+
req.query.category === '' ||
|
| 155 |
+
req.query.category === 'all'
|
| 156 |
+
) {
|
| 157 |
+
delete req.query.category;
|
| 158 |
+
} else {
|
| 159 |
+
const categoryValues = req.query.category.split(',');
|
| 160 |
+
const objectIds = [];
|
| 161 |
+
const numericIds = [];
|
| 162 |
+
const nameValues = [];
|
| 163 |
+
|
| 164 |
+
categoryValues.forEach((val) => {
|
| 165 |
+
if (mongoose.Types.ObjectId.isValid(val)) objectIds.push(val);
|
| 166 |
+
else if (/^\d+$/.test(val)) numericIds.push(val);
|
| 167 |
+
else nameValues.push(val);
|
| 168 |
+
});
|
| 169 |
+
|
| 170 |
+
// Find initial categories in bulk
|
| 171 |
+
const foundCategories = await Category.find({
|
| 172 |
+
$or: [
|
| 173 |
+
{ _id: { $in: objectIds } },
|
| 174 |
+
{ id: { $in: numericIds } },
|
| 175 |
+
{ nameAr: { $in: nameValues } },
|
| 176 |
+
{ nameEn: { $in: nameValues } },
|
| 177 |
+
],
|
| 178 |
+
});
|
| 179 |
+
|
| 180 |
+
const categoryIds = foundCategories.map((c) => c._id);
|
| 181 |
+
|
| 182 |
+
if (categoryIds.length > 0) {
|
| 183 |
+
// Get descendants (Level 1)
|
| 184 |
+
const subCategories = await Category.find({
|
| 185 |
+
parent: { $in: categoryIds },
|
| 186 |
+
});
|
| 187 |
+
const subIds = subCategories.map((c) => c._id);
|
| 188 |
+
categoryIds.push(...subIds);
|
| 189 |
+
|
| 190 |
+
if (subIds.length > 0) {
|
| 191 |
+
// Level 2 descendants
|
| 192 |
+
const subSubCategories = await Category.find({
|
| 193 |
+
parent: { $in: subIds },
|
| 194 |
+
});
|
| 195 |
+
categoryIds.push(...subSubCategories.map((c) => c._id));
|
| 196 |
+
}
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
if (categoryIds.length > 0) {
|
| 200 |
+
req.query.category = { $in: [...new Set(categoryIds)] };
|
| 201 |
+
} else {
|
| 202 |
+
req.query.category = { $in: [new mongoose.Types.ObjectId()] };
|
| 203 |
+
}
|
| 204 |
+
}
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
// 2) Filter by Brand (if provided)
|
| 208 |
+
if (req.query.brand) {
|
| 209 |
+
const brandValues = req.query.brand.split(',');
|
| 210 |
+
const isObjectId = mongoose.Types.ObjectId.isValid(brandValues[0]);
|
| 211 |
+
const isNumericId = /^\d+$/.test(brandValues[0]);
|
| 212 |
+
|
| 213 |
+
if (isObjectId) {
|
| 214 |
+
req.query.brand = { $in: brandValues };
|
| 215 |
+
} else if (isNumericId) {
|
| 216 |
+
// Find brand object by numeric ID
|
| 217 |
+
const brands = await Brand.find({ id: { $in: brandValues } });
|
| 218 |
+
if (brands.length > 0) {
|
| 219 |
+
// If product stores name, use names. If it stores ID, use IDs.
|
| 220 |
+
// For now, let's support both or prioritize names since the current model uses strings.
|
| 221 |
+
req.query.brand = { $in: brands.map((b) => b.nameEn) };
|
| 222 |
+
} else {
|
| 223 |
+
req.query.brand = { $in: ['Unknown Brand'] };
|
| 224 |
+
}
|
| 225 |
+
} else {
|
| 226 |
+
// It's a name
|
| 227 |
+
req.query.brand = { $in: brandValues };
|
| 228 |
+
}
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
// 3) Filter by Provider (if provided) - Supports multiple providers by name or slug
|
| 232 |
+
if (req.query.provider) {
|
| 233 |
+
const providerValues = req.query.provider.split(',');
|
| 234 |
+
const providers = await Provider.find({
|
| 235 |
+
$or: [
|
| 236 |
+
{ storeName: { $in: providerValues } },
|
| 237 |
+
{ slug: { $in: providerValues } },
|
| 238 |
+
{
|
| 239 |
+
_id: {
|
| 240 |
+
$in: providerValues.filter((id) =>
|
| 241 |
+
mongoose.Types.ObjectId.isValid(id),
|
| 242 |
+
),
|
| 243 |
+
},
|
| 244 |
+
},
|
| 245 |
+
],
|
| 246 |
+
});
|
| 247 |
+
|
| 248 |
+
if (providers.length > 0) {
|
| 249 |
+
req.query.provider = { $in: providers.map((p) => p._id) };
|
| 250 |
+
} else {
|
| 251 |
+
req.query.provider = { $in: ['000000000000000000000000'] };
|
| 252 |
+
}
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
// 3) Filter by Sale Price (if onSale is true)
|
| 256 |
+
let salePriceQuery = {};
|
| 257 |
+
if (req.query.onSale === 'true') {
|
| 258 |
+
salePriceQuery = { salePrice: { $gt: 0 } };
|
| 259 |
+
delete req.query.onSale;
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
// Store original search term for relevance scoring (before it gets deleted)
|
| 263 |
+
const originalSearchTerm = req.query.search
|
| 264 |
+
? convertArabicNumerals(req.query.search.trim())
|
| 265 |
+
: null;
|
| 266 |
+
const hasExplicitSort = !!req.query.sort;
|
| 267 |
+
|
| 268 |
+
// 4) Search by Name, Description, Barcode, or ID (if provided)
|
| 269 |
+
let searchQuery = {};
|
| 270 |
+
let relatedQuery = null; // $or query for "related" products (any-word match)
|
| 271 |
+
if (req.query.search) {
|
| 272 |
+
// TRACK SEARCH: Save the user's last 5 search terms in their profile if logged in
|
| 273 |
+
if (req.user) {
|
| 274 |
+
const searchTerm = req.query.search.trim();
|
| 275 |
+
if (searchTerm) {
|
| 276 |
+
// Remove if already exists to move it to the end (most recent)
|
| 277 |
+
const searchHistory = (req.user.recentSearches || []).filter(
|
| 278 |
+
(s) => s.toLowerCase() !== searchTerm.toLowerCase(),
|
| 279 |
+
);
|
| 280 |
+
searchHistory.push(searchTerm);
|
| 281 |
+
|
| 282 |
+
// Keep only last 5
|
| 283 |
+
if (searchHistory.length > 5) {
|
| 284 |
+
searchHistory.shift();
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
+
req.user.recentSearches = searchHistory;
|
| 288 |
+
// Non-blocking update
|
| 289 |
+
User.findByIdAndUpdate(req.user._id, {
|
| 290 |
+
recentSearches: searchHistory,
|
| 291 |
+
}).catch(() => {});
|
| 292 |
+
}
|
| 293 |
+
}
|
| 294 |
+
|
| 295 |
+
const searchTerms = req.query.search
|
| 296 |
+
.split(' ')
|
| 297 |
+
.map((term) => convertArabicNumerals(term.trim()))
|
| 298 |
+
.filter((term) => term.length > 0);
|
| 299 |
+
|
| 300 |
+
if (searchTerms.length > 0) {
|
| 301 |
+
// Pre-fetch all matching categories for all terms at once
|
| 302 |
+
const allSearchRegexes = searchTerms.map((term) =>
|
| 303 |
+
createSmartSearchRegex(term),
|
| 304 |
+
);
|
| 305 |
+
const allMatchingCategories = await Category.find({
|
| 306 |
+
$or: allSearchRegexes.flatMap((regex) => [
|
| 307 |
+
{ nameAr: regex },
|
| 308 |
+
{ nameEn: regex },
|
| 309 |
+
]),
|
| 310 |
+
}).select('_id nameAr nameEn');
|
| 311 |
+
|
| 312 |
+
const searchConditions = searchTerms.map((term) => {
|
| 313 |
+
const searchRegex = createSmartSearchRegex(term);
|
| 314 |
+
|
| 315 |
+
const matchingCategoryIds = allMatchingCategories
|
| 316 |
+
.filter(
|
| 317 |
+
(cat) =>
|
| 318 |
+
(cat.nameAr && cat.nameAr.match(searchRegex)) ||
|
| 319 |
+
(cat.nameEn && cat.nameEn.match(searchRegex)),
|
| 320 |
+
)
|
| 321 |
+
.map((cat) => cat._id);
|
| 322 |
+
|
| 323 |
+
// Build search conditions array
|
| 324 |
+
const conditions = [
|
| 325 |
+
{ nameAr: searchRegex },
|
| 326 |
+
{ nameEn: searchRegex },
|
| 327 |
+
{ descriptionAr: searchRegex },
|
| 328 |
+
{ descriptionEn: searchRegex },
|
| 329 |
+
{ barCode: searchRegex },
|
| 330 |
+
];
|
| 331 |
+
|
| 332 |
+
if (matchingCategoryIds && matchingCategoryIds.length > 0) {
|
| 333 |
+
conditions.push({ category: { $in: matchingCategoryIds } });
|
| 334 |
+
}
|
| 335 |
+
|
| 336 |
+
// Check if term looks like a hex string (potential partial ObjectId)
|
| 337 |
+
if (/^[0-9a-fA-F]+$/.test(term)) {
|
| 338 |
+
conditions.push({
|
| 339 |
+
$expr: {
|
| 340 |
+
$regexMatch: {
|
| 341 |
+
input: { $toString: '$_id' },
|
| 342 |
+
regex: term,
|
| 343 |
+
options: 'i',
|
| 344 |
+
},
|
| 345 |
+
},
|
| 346 |
+
});
|
| 347 |
+
}
|
| 348 |
+
|
| 349 |
+
return { $or: conditions };
|
| 350 |
+
});
|
| 351 |
+
|
| 352 |
+
searchQuery = { $and: searchConditions };
|
| 353 |
+
|
| 354 |
+
// Also build an $or query for "related" results (match ANY word).
|
| 355 |
+
// When multiple words are searched, this lets us append products that
|
| 356 |
+
// match some — but not all — of the words after the primary matches.
|
| 357 |
+
if (searchTerms.length > 1) {
|
| 358 |
+
// Flatten each per-word condition into one big $or
|
| 359 |
+
const relatedOrConditions = searchConditions.flatMap(
|
| 360 |
+
(c) => c.$or || [],
|
| 361 |
+
);
|
| 362 |
+
relatedQuery = { $or: relatedOrConditions, ...salePriceQuery };
|
| 363 |
+
}
|
| 364 |
+
}
|
| 365 |
+
|
| 366 |
+
// Remove search from query so it doesn't interfere with APIFeatures
|
| 367 |
+
delete req.query.search;
|
| 368 |
+
}
|
| 369 |
+
|
| 370 |
+
// Combine search and salePrice queries
|
| 371 |
+
const combinedQuery = { ...searchQuery, ...salePriceQuery };
|
| 372 |
+
|
| 373 |
+
// Calculate pagination params up front
|
| 374 |
+
const page = req.query.page * 1 || 1;
|
| 375 |
+
const limit = req.query.limit * 1 || 100;
|
| 376 |
+
|
| 377 |
+
// When search is active and no explicit sort, fetch ALL matches so we can
|
| 378 |
+
// score every result for relevance before paginating. This ensures the best
|
| 379 |
+
// match is always on page 1 regardless of insertion order in the DB.
|
| 380 |
+
let products;
|
| 381 |
+
let totalCount;
|
| 382 |
+
|
| 383 |
+
if (originalSearchTerm && !hasExplicitSort) {
|
| 384 |
+
// Fetch ALL primary matching products ($and — all words must match)
|
| 385 |
+
const allFeaturesQuery = new APIFeatures(
|
| 386 |
+
Product.find(combinedQuery)
|
| 387 |
+
.populate('category', 'nameAr _id')
|
| 388 |
+
.populate('provider', 'storeName _id'),
|
| 389 |
+
req.query,
|
| 390 |
+
)
|
| 391 |
+
.filter()
|
| 392 |
+
.sort()
|
| 393 |
+
.limitFields();
|
| 394 |
+
|
| 395 |
+
const primaryProducts = await allFeaturesQuery.query.lean();
|
| 396 |
+
const primaryIds = new Set(primaryProducts.map((p) => String(p._id)));
|
| 397 |
+
|
| 398 |
+
// Fetch related products ($or — match ANY word) that aren't already in primary set
|
| 399 |
+
let relatedProducts = [];
|
| 400 |
+
if (relatedQuery) {
|
| 401 |
+
relatedProducts = await Product.find({
|
| 402 |
+
...relatedQuery,
|
| 403 |
+
_id: { $nin: [...primaryIds] }, // exclude already-found primary products
|
| 404 |
+
})
|
| 405 |
+
.populate('category', 'nameAr _id')
|
| 406 |
+
.populate('provider', 'storeName _id')
|
| 407 |
+
.select('-__v')
|
| 408 |
+
.lean();
|
| 409 |
+
}
|
| 410 |
+
|
| 411 |
+
// Merge: primary first, related appended
|
| 412 |
+
// Scoring below will rank primary products higher (they match all words)
|
| 413 |
+
products = [...primaryProducts, ...relatedProducts];
|
| 414 |
+
totalCount = products.length;
|
| 415 |
+
} else {
|
| 416 |
+
// Regular path: paginate in DB (faster when no search or explicit sort)
|
| 417 |
+
const countFeatures = new APIFeatures(
|
| 418 |
+
Product.find(combinedQuery)
|
| 419 |
+
.populate('category', 'nameAr _id')
|
| 420 |
+
.populate('provider', 'storeName _id'),
|
| 421 |
+
req.query,
|
| 422 |
+
)
|
| 423 |
+
.filter()
|
| 424 |
+
.sort()
|
| 425 |
+
.limitFields();
|
| 426 |
+
|
| 427 |
+
totalCount = await Product.countDocuments(
|
| 428 |
+
countFeatures.query.getFilter(),
|
| 429 |
+
);
|
| 430 |
+
|
| 431 |
+
const features = new APIFeatures(
|
| 432 |
+
Product.find(combinedQuery)
|
| 433 |
+
.populate('category', 'nameAr _id')
|
| 434 |
+
.populate('provider', 'storeName _id'),
|
| 435 |
+
req.query,
|
| 436 |
+
)
|
| 437 |
+
.filter()
|
| 438 |
+
.sort()
|
| 439 |
+
.limitFields()
|
| 440 |
+
.paginate();
|
| 441 |
+
|
| 442 |
+
products = await features.query.lean();
|
| 443 |
+
}
|
| 444 |
+
|
| 445 |
+
// Apply intelligent relevance scoring if search is active and no explicit sort
|
| 446 |
+
if (originalSearchTerm && !hasExplicitSort && products.length > 0) {
|
| 447 |
+
// Normalize for Arabic-aware comparisons
|
| 448 |
+
const { normalizeArabic } = require('../utils/arabicSearch');
|
| 449 |
+
const normalizedSearchTerm = normalizeArabic(originalSearchTerm);
|
| 450 |
+
const searchWords = normalizedSearchTerm
|
| 451 |
+
.split(/\s+/)
|
| 452 |
+
.filter((w) => w.length > 0);
|
| 453 |
+
const isMultiWord = searchWords.length > 1;
|
| 454 |
+
|
| 455 |
+
/**
|
| 456 |
+
* Score a single string field against the search term.
|
| 457 |
+
* Returns a score 0-100 for that field.
|
| 458 |
+
*/
|
| 459 |
+
const IS_PURE_NUMBER = /^\d+(\.\d+)?$/;
|
| 460 |
+
const scoreField = (rawText) => {
|
| 461 |
+
if (!rawText) return 0;
|
| 462 |
+
const norm = normalizeArabic(rawText);
|
| 463 |
+
|
| 464 |
+
// --- Full-phrase matching ---
|
| 465 |
+
// Exact full match (always valid)
|
| 466 |
+
if (norm === normalizedSearchTerm) return 100;
|
| 467 |
+
|
| 468 |
+
// For pure-number queries (e.g. "5"), don't use startsWith/includes
|
| 469 |
+
// because "5" would falsely match "1500", "50", etc.
|
| 470 |
+
if (!IS_PURE_NUMBER.test(normalizedSearchTerm)) {
|
| 471 |
+
// Starts with full phrase
|
| 472 |
+
if (norm.startsWith(normalizedSearchTerm)) return 85;
|
| 473 |
+
// Contains the full phrase as a substring
|
| 474 |
+
if (norm.includes(normalizedSearchTerm)) return 65;
|
| 475 |
+
}
|
| 476 |
+
|
| 477 |
+
if (isMultiWord) {
|
| 478 |
+
// For multi-word queries: score based on how many words match in the name.
|
| 479 |
+
// Numbers must match as exact whole words (so "5" does NOT match "1500").
|
| 480 |
+
const IS_NUMBER = /^\d+(\.\d+)?$/;
|
| 481 |
+
let wordMatchCount = 0;
|
| 482 |
+
let allWordsMatched = true;
|
| 483 |
+
const normWords = norm.split(/\s+/);
|
| 484 |
+
|
| 485 |
+
for (const word of searchWords) {
|
| 486 |
+
let matched = false;
|
| 487 |
+
if (IS_NUMBER.test(word)) {
|
| 488 |
+
// Numeric token: require exact equality only
|
| 489 |
+
matched = normWords.some((nw) => nw === word);
|
| 490 |
+
} else {
|
| 491 |
+
// Text token: allow starts-with / contains (fuzzy)
|
| 492 |
+
matched = normWords.some(
|
| 493 |
+
(nw) => nw === word || nw.startsWith(word) || nw.includes(word),
|
| 494 |
+
);
|
| 495 |
+
}
|
| 496 |
+
if (matched) {
|
| 497 |
+
wordMatchCount++;
|
| 498 |
+
} else {
|
| 499 |
+
allWordsMatched = false;
|
| 500 |
+
}
|
| 501 |
+
}
|
| 502 |
+
|
| 503 |
+
if (wordMatchCount === 0) return 0;
|
| 504 |
+
const ratio = wordMatchCount / searchWords.length;
|
| 505 |
+
// All words matched → high score
|
| 506 |
+
if (ratio === 1) return allWordsMatched ? 80 : 72;
|
| 507 |
+
// Partial match: scale 10-58
|
| 508 |
+
return 10 + Math.round(ratio * 48);
|
| 509 |
+
}
|
| 510 |
+
|
| 511 |
+
// Single-word fallback
|
| 512 |
+
// Whole-word boundary match
|
| 513 |
+
try {
|
| 514 |
+
const wbRegex = new RegExp(
|
| 515 |
+
`(^|\\s)${normalizedSearchTerm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(\\s|$)`,
|
| 516 |
+
);
|
| 517 |
+
if (wbRegex.test(norm)) return 60;
|
| 518 |
+
} catch (_) {
|
| 519 |
+
// ignore regex errors
|
| 520 |
+
}
|
| 521 |
+
|
| 522 |
+
return 0;
|
| 523 |
+
};
|
| 524 |
+
|
| 525 |
+
/**
|
| 526 |
+
* Combined scorer for a product.
|
| 527 |
+
* Name scores are weighted heavily; description/category as fallback.
|
| 528 |
+
*/
|
| 529 |
+
const scoreProduct = (product) => {
|
| 530 |
+
const nameArScore = scoreField(product.nameAr);
|
| 531 |
+
const nameEnScore = scoreField(product.nameEn);
|
| 532 |
+
const nameScore = Math.max(nameArScore, nameEnScore);
|
| 533 |
+
|
| 534 |
+
// If we got a solid name match, return it directly
|
| 535 |
+
if (nameScore >= 60) return nameScore;
|
| 536 |
+
|
| 537 |
+
// Check description
|
| 538 |
+
const descArText = Array.isArray(product.descriptionAr)
|
| 539 |
+
? product.descriptionAr.join(' ')
|
| 540 |
+
: product.descriptionAr || '';
|
| 541 |
+
const descEnText = Array.isArray(product.descriptionEn)
|
| 542 |
+
? product.descriptionEn.join(' ')
|
| 543 |
+
: product.descriptionEn || '';
|
| 544 |
+
const descScore = Math.max(
|
| 545 |
+
scoreField(descArText),
|
| 546 |
+
scoreField(descEnText),
|
| 547 |
+
);
|
| 548 |
+
|
| 549 |
+
if (descScore > 0)
|
| 550 |
+
return Math.max(nameScore, Math.round(descScore * 0.4));
|
| 551 |
+
|
| 552 |
+
// Matched via category/barcode — keep it low but above 0
|
| 553 |
+
return nameScore > 0 ? nameScore : 8;
|
| 554 |
+
};
|
| 555 |
+
|
| 556 |
+
// Sort products by score (descending)
|
| 557 |
+
products = products.sort((a, b) => scoreProduct(b) - scoreProduct(a));
|
| 558 |
+
|
| 559 |
+
// Apply manual pagination after scoring (only for search path)
|
| 560 |
+
if (originalSearchTerm && !hasExplicitSort) {
|
| 561 |
+
const skip = (page - 1) * limit;
|
| 562 |
+
products = products.slice(skip, skip + limit);
|
| 563 |
+
}
|
| 564 |
+
}
|
| 565 |
+
|
| 566 |
+
const totalPages = Math.ceil(totalCount / limit);
|
| 567 |
+
|
| 568 |
+
res.status(200).json({
|
| 569 |
+
status: 'success',
|
| 570 |
+
results: products.length,
|
| 571 |
+
totalCount,
|
| 572 |
+
totalPages,
|
| 573 |
+
currentPage: page,
|
| 574 |
+
data: {
|
| 575 |
+
products,
|
| 576 |
+
},
|
| 577 |
+
});
|
| 578 |
+
} catch (err) {
|
| 579 |
+
console.error('getAllProducts error:', err);
|
| 580 |
+
res.status(500).json({
|
| 581 |
+
status: 'fail',
|
| 582 |
+
message: 'Error fetching products',
|
| 583 |
+
error: err.message,
|
| 584 |
+
});
|
| 585 |
+
}
|
| 586 |
+
};
|
| 587 |
+
|
| 588 |
+
// Get search suggestions
|
| 589 |
+
exports.getSuggestions = async (req, res) => {
|
| 590 |
+
try {
|
| 591 |
+
const { search } = req.query;
|
| 592 |
+
if (!search) {
|
| 593 |
+
return res
|
| 594 |
+
.status(200)
|
| 595 |
+
.json({ status: 'success', data: { products: [], categories: [] } });
|
| 596 |
+
}
|
| 597 |
+
|
| 598 |
+
const {
|
| 599 |
+
createSmartSearchRegex,
|
| 600 |
+
getSimilarity,
|
| 601 |
+
convertArabicNumerals,
|
| 602 |
+
} = require('../utils/arabicSearch');
|
| 603 |
+
|
| 604 |
+
const searchTerms = search
|
| 605 |
+
.split(' ')
|
| 606 |
+
.map((term) => convertArabicNumerals(term.trim()))
|
| 607 |
+
.filter((term) => term.length > 0);
|
| 608 |
+
let productQuery = {};
|
| 609 |
+
let categoryQuery = {};
|
| 610 |
+
|
| 611 |
+
if (searchTerms.length > 0) {
|
| 612 |
+
const searchConditions = searchTerms.map((term) => {
|
| 613 |
+
const searchRegex = createSmartSearchRegex(term);
|
| 614 |
+
return {
|
| 615 |
+
$or: [{ nameAr: searchRegex }, { nameEn: searchRegex }],
|
| 616 |
+
};
|
| 617 |
+
});
|
| 618 |
+
|
| 619 |
+
productQuery = { $and: searchConditions };
|
| 620 |
+
categoryQuery = { $and: searchConditions };
|
| 621 |
+
}
|
| 622 |
+
|
| 623 |
+
// Fetch matching products (fetch more for ranking)
|
| 624 |
+
let products = await Product.find(productQuery)
|
| 625 |
+
.select('nameAr nameEn imageCover price slug _id')
|
| 626 |
+
.limit(100)
|
| 627 |
+
.lean();
|
| 628 |
+
|
| 629 |
+
// Rank products by similarity
|
| 630 |
+
products = products
|
| 631 |
+
.map((p) => ({
|
| 632 |
+
...p,
|
| 633 |
+
score: Math.max(
|
| 634 |
+
getSimilarity(search, p.nameAr),
|
| 635 |
+
getSimilarity(search, p.nameEn),
|
| 636 |
+
),
|
| 637 |
+
}))
|
| 638 |
+
.sort((a, b) => b.score - a.score)
|
| 639 |
+
.slice(0, 5);
|
| 640 |
+
|
| 641 |
+
// Fetch matching categories
|
| 642 |
+
let categories = await Category.find(categoryQuery)
|
| 643 |
+
.select('nameAr nameEn _id id')
|
| 644 |
+
.limit(50)
|
| 645 |
+
.lean();
|
| 646 |
+
|
| 647 |
+
// Rank categories by similarity
|
| 648 |
+
categories = categories
|
| 649 |
+
.map((c) => ({
|
| 650 |
+
...c,
|
| 651 |
+
score: Math.max(
|
| 652 |
+
getSimilarity(search, c.nameAr),
|
| 653 |
+
getSimilarity(search, c.nameEn),
|
| 654 |
+
),
|
| 655 |
+
}))
|
| 656 |
+
.sort((a, b) => b.score - a.score)
|
| 657 |
+
.slice(0, 3);
|
| 658 |
+
|
| 659 |
+
res.status(200).json({
|
| 660 |
+
status: 'success',
|
| 661 |
+
data: {
|
| 662 |
+
products,
|
| 663 |
+
categories,
|
| 664 |
+
},
|
| 665 |
+
});
|
| 666 |
+
} catch (err) {
|
| 667 |
+
res.status(500).json({
|
| 668 |
+
status: 'fail',
|
| 669 |
+
message: 'Error fetching suggestions',
|
| 670 |
+
});
|
| 671 |
+
}
|
| 672 |
+
};
|
| 673 |
+
|
| 674 |
+
// Voice-based technical search (transcript -> entities -> filters -> ranking)
|
| 675 |
+
exports.voiceSearch = async (req, res) => {
|
| 676 |
+
try {
|
| 677 |
+
const { transcript = '', limit = 12 } = req.body || {};
|
| 678 |
+
|
| 679 |
+
if (!transcript || !String(transcript).trim()) {
|
| 680 |
+
return res.status(400).json({
|
| 681 |
+
status: 'fail',
|
| 682 |
+
message: 'transcript is required',
|
| 683 |
+
});
|
| 684 |
+
}
|
| 685 |
+
|
| 686 |
+
const cappedLimit = Math.min(Math.max(Number(limit) || 12, 1), 40);
|
| 687 |
+
|
| 688 |
+
const categories = await Category.find({})
|
| 689 |
+
.select('_id id nameAr nameEn')
|
| 690 |
+
.lean();
|
| 691 |
+
|
| 692 |
+
const extraction = extractVoiceEntities({
|
| 693 |
+
transcript: String(transcript),
|
| 694 |
+
categories,
|
| 695 |
+
});
|
| 696 |
+
|
| 697 |
+
const { entities } = extraction;
|
| 698 |
+
|
| 699 |
+
const queryEntities = {
|
| 700 |
+
categoryId: entities.category?.id || null,
|
| 701 |
+
categoryObjectId: entities.category?.objectId || null,
|
| 702 |
+
powerType: entities.powerType,
|
| 703 |
+
voltage: entities.voltage,
|
| 704 |
+
powerWatts: entities.powerWatts,
|
| 705 |
+
sizeMm: entities.sizeMm,
|
| 706 |
+
usage: entities.usage,
|
| 707 |
+
minPrice: entities.minPrice,
|
| 708 |
+
maxPrice: entities.maxPrice,
|
| 709 |
+
keywords: Array.isArray(entities.keywords) ? entities.keywords : [],
|
| 710 |
+
};
|
| 711 |
+
|
| 712 |
+
const mongoQuery = buildMongoVoiceQuery(queryEntities);
|
| 713 |
+
let candidates = await Product.find(mongoQuery)
|
| 714 |
+
.populate('category', 'nameAr nameEn _id id')
|
| 715 |
+
.populate('provider', 'storeName _id')
|
| 716 |
+
.limit(cappedLimit * 5)
|
| 717 |
+
.lean();
|
| 718 |
+
|
| 719 |
+
// Strong fallback: if no direct results, search broad set with optional price filter.
|
| 720 |
+
if (candidates.length === 0) {
|
| 721 |
+
const fallbackQuery = {};
|
| 722 |
+
|
| 723 |
+
if (queryEntities.minPrice !== null || queryEntities.maxPrice !== null) {
|
| 724 |
+
fallbackQuery.price = {};
|
| 725 |
+
if (queryEntities.minPrice !== null) {
|
| 726 |
+
fallbackQuery.price.$gte = queryEntities.minPrice;
|
| 727 |
+
}
|
| 728 |
+
if (queryEntities.maxPrice !== null) {
|
| 729 |
+
fallbackQuery.price.$lte = queryEntities.maxPrice;
|
| 730 |
+
}
|
| 731 |
+
}
|
| 732 |
+
|
| 733 |
+
candidates = await Product.find(fallbackQuery)
|
| 734 |
+
.populate('category', 'nameAr nameEn _id id')
|
| 735 |
+
.populate('provider', 'storeName _id')
|
| 736 |
+
.sort('-createdAt')
|
| 737 |
+
.limit(cappedLimit * 10)
|
| 738 |
+
.lean();
|
| 739 |
+
}
|
| 740 |
+
|
| 741 |
+
const rankedProducts = candidates
|
| 742 |
+
.map((product) => ({
|
| 743 |
+
...product,
|
| 744 |
+
relevanceScore: scoreVoiceProduct(product, queryEntities),
|
| 745 |
+
}))
|
| 746 |
+
.sort((a, b) => b.relevanceScore - a.relevanceScore)
|
| 747 |
+
.slice(0, cappedLimit);
|
| 748 |
+
|
| 749 |
+
const cleanedSearchText =
|
| 750 |
+
buildSearchTextFromEntities(queryEntities) || transcript;
|
| 751 |
+
const finalSearchText =
|
| 752 |
+
cleanedSearchText.trim().length > 0 ? cleanedSearchText : transcript;
|
| 753 |
+
|
| 754 |
+
const queryParams = {
|
| 755 |
+
...buildVoiceFilterQuery(queryEntities),
|
| 756 |
+
search: finalSearchText,
|
| 757 |
+
};
|
| 758 |
+
|
| 759 |
+
return res.status(200).json({
|
| 760 |
+
status: 'success',
|
| 761 |
+
results: rankedProducts.length,
|
| 762 |
+
data: {
|
| 763 |
+
transcript,
|
| 764 |
+
intent: extraction.intent,
|
| 765 |
+
entities,
|
| 766 |
+
queryParams,
|
| 767 |
+
mongoQuery,
|
| 768 |
+
candidatesFound: candidates.length,
|
| 769 |
+
products: rankedProducts,
|
| 770 |
+
},
|
| 771 |
+
});
|
| 772 |
+
} catch (err) {
|
| 773 |
+
console.error('Voice search error:', err.message);
|
| 774 |
+
return res.status(500).json({
|
| 775 |
+
status: 'fail',
|
| 776 |
+
message: 'Error processing voice search',
|
| 777 |
+
});
|
| 778 |
+
}
|
| 779 |
+
};
|
| 780 |
+
|
| 781 |
+
exports.transcribeVoice = async (req, res) => {
|
| 782 |
+
try {
|
| 783 |
+
if (!process.env.OPENAI_API_KEY) {
|
| 784 |
+
return res.status(500).json({
|
| 785 |
+
status: 'fail',
|
| 786 |
+
message: 'OPENAI_API_KEY is not configured on the backend',
|
| 787 |
+
});
|
| 788 |
+
}
|
| 789 |
+
|
| 790 |
+
if (!req.file || !req.file.buffer) {
|
| 791 |
+
return res.status(400).json({
|
| 792 |
+
status: 'fail',
|
| 793 |
+
message: 'audio file is required',
|
| 794 |
+
});
|
| 795 |
+
}
|
| 796 |
+
|
| 797 |
+
const formData = new FormData();
|
| 798 |
+
const contentType = req.file.mimetype || 'audio/webm';
|
| 799 |
+
formData.append('file', req.file.buffer, {
|
| 800 |
+
filename: req.file.originalname || 'voice.webm',
|
| 801 |
+
contentType,
|
| 802 |
+
});
|
| 803 |
+
formData.append('model', process.env.OPENAI_WHISPER_MODEL || 'whisper-1');
|
| 804 |
+
|
| 805 |
+
const language = req.body?.language;
|
| 806 |
+
if (language) {
|
| 807 |
+
formData.append('language', String(language).slice(0, 5));
|
| 808 |
+
}
|
| 809 |
+
|
| 810 |
+
const whisperResponse = await axios.post(
|
| 811 |
+
'https://api.openai.com/v1/audio/transcriptions',
|
| 812 |
+
formData,
|
| 813 |
+
{
|
| 814 |
+
headers: {
|
| 815 |
+
Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
|
| 816 |
+
...formData.getHeaders(),
|
| 817 |
+
},
|
| 818 |
+
maxBodyLength: Infinity,
|
| 819 |
+
timeout: 30000,
|
| 820 |
+
},
|
| 821 |
+
);
|
| 822 |
+
|
| 823 |
+
const transcript = whisperResponse?.data?.text?.trim() || '';
|
| 824 |
+
|
| 825 |
+
return res.status(200).json({
|
| 826 |
+
status: 'success',
|
| 827 |
+
data: {
|
| 828 |
+
transcript,
|
| 829 |
+
},
|
| 830 |
+
});
|
| 831 |
+
} catch (error) {
|
| 832 |
+
return res.status(500).json({
|
| 833 |
+
status: 'fail',
|
| 834 |
+
message: 'Failed to transcribe voice using Whisper API',
|
| 835 |
+
error:
|
| 836 |
+
error?.response?.data?.error?.message ||
|
| 837 |
+
error.message ||
|
| 838 |
+
'Unknown error',
|
| 839 |
+
});
|
| 840 |
+
}
|
| 841 |
+
};
|
| 842 |
+
|
| 843 |
+
// Get a single product by ID
|
| 844 |
+
exports.getProduct = async (req, res) => {
|
| 845 |
+
try {
|
| 846 |
+
const { id } = req.params;
|
| 847 |
+
|
| 848 |
+
// Check if the provided ID is a valid MongoDB ObjectId
|
| 849 |
+
const isObjectId = mongoose.Types.ObjectId.isValid(id);
|
| 850 |
+
|
| 851 |
+
let product;
|
| 852 |
+
if (isObjectId) {
|
| 853 |
+
product = await Product.findById(id)
|
| 854 |
+
.populate('category', 'nameAr nameEn _id id')
|
| 855 |
+
.populate('provider', 'storeName name logo _id')
|
| 856 |
+
.populate('reviews')
|
| 857 |
+
.lean();
|
| 858 |
+
} else {
|
| 859 |
+
// If not an ObjectId, assume it's a slug
|
| 860 |
+
product = await Product.findOne({ slug: id })
|
| 861 |
+
.populate('category', 'nameAr nameEn _id id')
|
| 862 |
+
.populate('provider', 'storeName name logo _id')
|
| 863 |
+
.populate('reviews')
|
| 864 |
+
.lean();
|
| 865 |
+
}
|
| 866 |
+
|
| 867 |
+
if (!product) {
|
| 868 |
+
return res.status(404).json({
|
| 869 |
+
status: 'fail',
|
| 870 |
+
message: 'No product found with that ID or slug',
|
| 871 |
+
});
|
| 872 |
+
}
|
| 873 |
+
|
| 874 |
+
res.status(200).json({
|
| 875 |
+
status: 'success',
|
| 876 |
+
data: {
|
| 877 |
+
product,
|
| 878 |
+
},
|
| 879 |
+
});
|
| 880 |
+
} catch (err) {
|
| 881 |
+
res.status(500).json({
|
| 882 |
+
status: 'fail',
|
| 883 |
+
message: err.message,
|
| 884 |
+
});
|
| 885 |
+
}
|
| 886 |
+
};
|
| 887 |
+
|
| 888 |
+
// Create a new product
|
| 889 |
+
exports.createProduct = async (req, res) => {
|
| 890 |
+
try {
|
| 891 |
+
const categoryIds = Array.isArray(req.body.category)
|
| 892 |
+
? req.body.category
|
| 893 |
+
: [req.body.category];
|
| 894 |
+
|
| 895 |
+
const categories = await Category.find({ _id: { $in: categoryIds } });
|
| 896 |
+
if (!categories || categories.length === 0) {
|
| 897 |
+
return res.status(400).json({ message: 'Invalid category ids' });
|
| 898 |
+
}
|
| 899 |
+
|
| 900 |
+
const provider = await Provider.findById(req.body.provider);
|
| 901 |
+
if (!provider) {
|
| 902 |
+
return res.status(400).json({ message: 'Invalid provider id' });
|
| 903 |
+
}
|
| 904 |
+
|
| 905 |
+
const productData = {
|
| 906 |
+
// eslint-disable-next-line node/no-unsupported-features/es-syntax
|
| 907 |
+
...req.body,
|
| 908 |
+
category: categories.map((cat) => cat._id),
|
| 909 |
+
provider: provider._id,
|
| 910 |
+
};
|
| 911 |
+
|
| 912 |
+
const newProduct = await Product.create(productData);
|
| 913 |
+
|
| 914 |
+
// Notify AI service to reload cache
|
| 915 |
+
triggerAiReload();
|
| 916 |
+
|
| 917 |
+
res.status(201).json({
|
| 918 |
+
status: 'success',
|
| 919 |
+
data: {
|
| 920 |
+
product: newProduct,
|
| 921 |
+
},
|
| 922 |
+
});
|
| 923 |
+
} catch (err) {
|
| 924 |
+
res.status(400).json({
|
| 925 |
+
status: 'fail',
|
| 926 |
+
message: err.message,
|
| 927 |
+
});
|
| 928 |
+
}
|
| 929 |
+
};
|
| 930 |
+
|
| 931 |
+
// Update a product
|
| 932 |
+
exports.updateProduct = async (req, res) => {
|
| 933 |
+
try {
|
| 934 |
+
const oldProduct = await Product.findById(req.params.id);
|
| 935 |
+
if (!oldProduct) {
|
| 936 |
+
return res.status(404).json({
|
| 937 |
+
status: 'fail',
|
| 938 |
+
message: 'Product not found',
|
| 939 |
+
});
|
| 940 |
+
}
|
| 941 |
+
|
| 942 |
+
const updatedProduct = await Product.findByIdAndUpdate(
|
| 943 |
+
req.params.id,
|
| 944 |
+
req.body,
|
| 945 |
+
{ new: true, runValidators: true },
|
| 946 |
+
);
|
| 947 |
+
|
| 948 |
+
// Check for stock replenish (Back in Stock)
|
| 949 |
+
if (req.body.stock > 0 && (!oldProduct.stock || oldProduct.stock <= 0)) {
|
| 950 |
+
notifyPastBuyersProductBackInStock(updatedProduct).catch((err) =>
|
| 951 |
+
console.error(
|
| 952 |
+
'Failed to send back-in-stock notification (past buyers):',
|
| 953 |
+
err.message,
|
| 954 |
+
),
|
| 955 |
+
);
|
| 956 |
+
notifyStockSubscribers(updatedProduct).catch((err) =>
|
| 957 |
+
console.error(
|
| 958 |
+
'Failed to send back-in-stock notification (subscribers):',
|
| 959 |
+
err.message,
|
| 960 |
+
),
|
| 961 |
+
);
|
| 962 |
+
}
|
| 963 |
+
|
| 964 |
+
// Notify AI service to reload cache (any data change)
|
| 965 |
+
triggerAiReload();
|
| 966 |
+
|
| 967 |
+
res.status(200).json({
|
| 968 |
+
status: 'success',
|
| 969 |
+
data: {
|
| 970 |
+
product: updatedProduct,
|
| 971 |
+
},
|
| 972 |
+
});
|
| 973 |
+
} catch (err) {
|
| 974 |
+
res.status(500).json({
|
| 975 |
+
status: 'fail',
|
| 976 |
+
message: err.message,
|
| 977 |
+
});
|
| 978 |
+
}
|
| 979 |
+
};
|
| 980 |
+
|
| 981 |
+
// Delete a product
|
| 982 |
+
const { deleteProductFolder } = require('../config/cloudinary');
|
| 983 |
+
|
| 984 |
+
// ... (existing imports)
|
| 985 |
+
|
| 986 |
+
// Delete a product
|
| 987 |
+
exports.deleteProduct = async (req, res) => {
|
| 988 |
+
try {
|
| 989 |
+
// Delete product from MongoDB
|
| 990 |
+
const product = await Product.findByIdAndDelete(req.params.id);
|
| 991 |
+
|
| 992 |
+
if (!product) {
|
| 993 |
+
return res.status(404).json({
|
| 994 |
+
status: 'fail',
|
| 995 |
+
message: 'No product found with that ID',
|
| 996 |
+
});
|
| 997 |
+
}
|
| 998 |
+
|
| 999 |
+
// Delete associated images folder from Cloudinary
|
| 1000 |
+
try {
|
| 1001 |
+
await deleteProductFolder(req.params.id);
|
| 1002 |
+
} catch (imageError) {
|
| 1003 |
+
console.error(
|
| 1004 |
+
`Failed to delete images for product ${req.params.id}:`,
|
| 1005 |
+
imageError,
|
| 1006 |
+
);
|
| 1007 |
+
// We don't stop the response here, as the product is already deleted
|
| 1008 |
+
}
|
| 1009 |
+
|
| 1010 |
+
res.status(204).json({
|
| 1011 |
+
status: 'success',
|
| 1012 |
+
data: null,
|
| 1013 |
+
});
|
| 1014 |
+
} catch (err) {
|
| 1015 |
+
res.status(404).json({
|
| 1016 |
+
status: 'fail',
|
| 1017 |
+
message: err.message,
|
| 1018 |
+
});
|
| 1019 |
+
}
|
| 1020 |
+
};
|
| 1021 |
+
|
| 1022 |
+
// Get product statistics (Admin)
|
| 1023 |
+
exports.getProductStats = async (req, res) => {
|
| 1024 |
+
try {
|
| 1025 |
+
const stats = await Product.aggregate([
|
| 1026 |
+
{
|
| 1027 |
+
$facet: {
|
| 1028 |
+
totalProducts: [{ $count: 'count' }],
|
| 1029 |
+
available: [{ $match: { stock: { $gt: 10 } } }, { $count: 'count' }],
|
| 1030 |
+
lowStock: [
|
| 1031 |
+
{ $match: { stock: { $gt: 0, $lte: 10 } } },
|
| 1032 |
+
{ $count: 'count' },
|
| 1033 |
+
],
|
| 1034 |
+
outOfStock: [{ $match: { stock: 0 } }, { $count: 'count' }],
|
| 1035 |
+
},
|
| 1036 |
+
},
|
| 1037 |
+
]);
|
| 1038 |
+
|
| 1039 |
+
const result = {
|
| 1040 |
+
total:
|
| 1041 |
+
stats[0].totalProducts && stats[0].totalProducts[0]
|
| 1042 |
+
? stats[0].totalProducts[0].count
|
| 1043 |
+
: 0,
|
| 1044 |
+
available:
|
| 1045 |
+
stats[0].available && stats[0].available[0]
|
| 1046 |
+
? stats[0].available[0].count
|
| 1047 |
+
: 0,
|
| 1048 |
+
lowStock:
|
| 1049 |
+
stats[0].lowStock && stats[0].lowStock[0]
|
| 1050 |
+
? stats[0].lowStock[0].count
|
| 1051 |
+
: 0,
|
| 1052 |
+
outOfStock:
|
| 1053 |
+
stats[0].outOfStock && stats[0].outOfStock[0]
|
| 1054 |
+
? stats[0].outOfStock[0].count
|
| 1055 |
+
: 0,
|
| 1056 |
+
};
|
| 1057 |
+
|
| 1058 |
+
res.status(200).json({
|
| 1059 |
+
status: 'success',
|
| 1060 |
+
data: result,
|
| 1061 |
+
});
|
| 1062 |
+
} catch (err) {
|
| 1063 |
+
res.status(500).json({
|
| 1064 |
+
status: 'fail',
|
| 1065 |
+
message: 'Error fetching product statistics',
|
| 1066 |
+
});
|
| 1067 |
+
}
|
| 1068 |
+
};
|
| 1069 |
+
|
| 1070 |
+
// Get featured products
|
| 1071 |
+
exports.getFeaturedProducts = async (req, res) => {
|
| 1072 |
+
try {
|
| 1073 |
+
const products = await Product.find({ isFeatured: true })
|
| 1074 |
+
.populate('category', 'nameAr _id')
|
| 1075 |
+
.populate('provider', 'storeName _id')
|
| 1076 |
+
.limit(20)
|
| 1077 |
+
.lean(); // Limit to 20 for performance (Home Page Carousel)
|
| 1078 |
+
|
| 1079 |
+
res.status(200).json({
|
| 1080 |
+
status: 'success',
|
| 1081 |
+
results: products.length,
|
| 1082 |
+
data: {
|
| 1083 |
+
products,
|
| 1084 |
+
},
|
| 1085 |
+
});
|
| 1086 |
+
} catch (err) {
|
| 1087 |
+
res.status(500).json({
|
| 1088 |
+
status: 'fail',
|
| 1089 |
+
message: 'Error fetching featured products',
|
| 1090 |
+
});
|
| 1091 |
+
}
|
| 1092 |
+
};
|
| 1093 |
+
|
| 1094 |
+
// Import products from Excel file
|
| 1095 |
+
exports.importProducts = async (req, res) => {
|
| 1096 |
+
try {
|
| 1097 |
+
if (!req.file) {
|
| 1098 |
+
return res
|
| 1099 |
+
.status(400)
|
| 1100 |
+
.json({ status: 'fail', message: 'No file uploaded' });
|
| 1101 |
+
}
|
| 1102 |
+
|
| 1103 |
+
const { preview } = req.query;
|
| 1104 |
+
const isPreview = preview === 'true';
|
| 1105 |
+
|
| 1106 |
+
// Read from buffer instead of file path (for Vercel serverless)
|
| 1107 |
+
const workbook = xlsx.read(req.file.buffer, { type: 'buffer' });
|
| 1108 |
+
const sheetName = workbook.SheetNames[0];
|
| 1109 |
+
const worksheet = workbook.Sheets[sheetName];
|
| 1110 |
+
const rows = xlsx.utils.sheet_to_json(worksheet);
|
| 1111 |
+
|
| 1112 |
+
const newProducts = [];
|
| 1113 |
+
const updatedProducts = [];
|
| 1114 |
+
const missingProducts = [];
|
| 1115 |
+
const errors = [];
|
| 1116 |
+
|
| 1117 |
+
// Pre-fetch all needed providers and categories to optimize
|
| 1118 |
+
// However, for simplicity and ensuring exact matches per row logic, we'll keep it loop-based
|
| 1119 |
+
// or we could optimize if performance becomes an issue.
|
| 1120 |
+
const isVendor = req.user.role === 'vendor';
|
| 1121 |
+
let vendorProvider = null;
|
| 1122 |
+
|
| 1123 |
+
if (isVendor) {
|
| 1124 |
+
if (!req.user.provider) {
|
| 1125 |
+
return res.status(400).json({
|
| 1126 |
+
status: 'fail',
|
| 1127 |
+
message: 'Vendor user is not associated with any provider',
|
| 1128 |
+
});
|
| 1129 |
+
}
|
| 1130 |
+
// eslint-disable-next-line no-await-in-loop
|
| 1131 |
+
vendorProvider = await Provider.findById(req.user.provider);
|
| 1132 |
+
if (!vendorProvider) {
|
| 1133 |
+
return res.status(400).json({
|
| 1134 |
+
status: 'fail',
|
| 1135 |
+
message: 'Associated provider not found',
|
| 1136 |
+
});
|
| 1137 |
+
}
|
| 1138 |
+
}
|
| 1139 |
+
|
| 1140 |
+
// Deduplicate rows: if the same barCode+provider combo appears more than once in the
|
| 1141 |
+
// sheet, only the LAST occurrence is processed (later row is the intended final version).
|
| 1142 |
+
const lastOccurrenceMap = new Map();
|
| 1143 |
+
rows.forEach((row, idx) => {
|
| 1144 |
+
const providerKey = isVendor
|
| 1145 |
+
? '__vendor__'
|
| 1146 |
+
: row.provider
|
| 1147 |
+
? row.provider.toString().trim()
|
| 1148 |
+
: '';
|
| 1149 |
+
const key = `${(row.barCode || '').toString().trim()}||${providerKey}`;
|
| 1150 |
+
lastOccurrenceMap.set(key, idx);
|
| 1151 |
+
});
|
| 1152 |
+
|
| 1153 |
+
for (const [index, row] of rows.entries()) {
|
| 1154 |
+
try {
|
| 1155 |
+
// Skip rows superseded by a later row with the same barCode+provider combo
|
| 1156 |
+
const dupProviderKey = isVendor
|
| 1157 |
+
? '__vendor__'
|
| 1158 |
+
: row.provider
|
| 1159 |
+
? row.provider.toString().trim()
|
| 1160 |
+
: '';
|
| 1161 |
+
const dupKey = `${(row.barCode || '').toString().trim()}||${dupProviderKey}`;
|
| 1162 |
+
if (lastOccurrenceMap.get(dupKey) !== index) {
|
| 1163 |
+
// A later row covers this barCode+provider — skip this one
|
| 1164 |
+
// eslint-disable-next-line no-continue
|
| 1165 |
+
continue;
|
| 1166 |
+
}
|
| 1167 |
+
|
| 1168 |
+
// ── DETECT MODE FIRST ──────────────────────────────────────────────────
|
| 1169 |
+
// A "category-only" sheet has just barCode + category and nothing else.
|
| 1170 |
+
// We must detect this BEFORE provider resolution so we don't block on it.
|
| 1171 |
+
const isCategoryOnlyUpdate =
|
| 1172 |
+
row.barCode &&
|
| 1173 |
+
row.category &&
|
| 1174 |
+
!row.nameAr &&
|
| 1175 |
+
!row.brand &&
|
| 1176 |
+
!row.provider &&
|
| 1177 |
+
!row.price;
|
| 1178 |
+
|
| 1179 |
+
const hasPurchaseOrPrice =
|
| 1180 |
+
row.purchasePrice !== undefined || row.price !== undefined;
|
| 1181 |
+
|
| 1182 |
+
const isPriceOnlyUpdate =
|
| 1183 |
+
row.barCode &&
|
| 1184 |
+
hasPurchaseOrPrice &&
|
| 1185 |
+
!row.nameAr &&
|
| 1186 |
+
!row.nameEn &&
|
| 1187 |
+
!row.brand &&
|
| 1188 |
+
!row.category &&
|
| 1189 |
+
!row.salePrice &&
|
| 1190 |
+
!row.stock &&
|
| 1191 |
+
!row.descriptionAr &&
|
| 1192 |
+
!row.descriptionEn &&
|
| 1193 |
+
!row.imageCover &&
|
| 1194 |
+
!row.images &&
|
| 1195 |
+
!row.isFeatured &&
|
| 1196 |
+
!row.ratingsAverage &&
|
| 1197 |
+
!row.ratingsQuantity;
|
| 1198 |
+
|
| 1199 |
+
const isPriceOnlyMissingRequired =
|
| 1200 |
+
row.barCode &&
|
| 1201 |
+
!hasPurchaseOrPrice &&
|
| 1202 |
+
row.costPrice !== undefined &&
|
| 1203 |
+
!row.nameAr &&
|
| 1204 |
+
!row.nameEn &&
|
| 1205 |
+
!row.brand &&
|
| 1206 |
+
!row.category &&
|
| 1207 |
+
!row.price &&
|
| 1208 |
+
!row.salePrice &&
|
| 1209 |
+
!row.stock &&
|
| 1210 |
+
!row.descriptionAr &&
|
| 1211 |
+
!row.descriptionEn &&
|
| 1212 |
+
!row.imageCover &&
|
| 1213 |
+
!row.images &&
|
| 1214 |
+
!row.isFeatured &&
|
| 1215 |
+
!row.ratingsAverage &&
|
| 1216 |
+
!row.ratingsQuantity;
|
| 1217 |
+
|
| 1218 |
+
const isBroadBarcodeUpdate =
|
| 1219 |
+
!isVendor &&
|
| 1220 |
+
(isCategoryOnlyUpdate || (isPriceOnlyUpdate && !row.provider));
|
| 1221 |
+
|
| 1222 |
+
if (isPriceOnlyMissingRequired) {
|
| 1223 |
+
errors.push({
|
| 1224 |
+
row: index + 2,
|
| 1225 |
+
message:
|
| 1226 |
+
'Price-only update requires purchasePrice or price (costPrice is optional)',
|
| 1227 |
+
name: row.nameAr || 'Unknown',
|
| 1228 |
+
});
|
| 1229 |
+
// eslint-disable-next-line no-continue
|
| 1230 |
+
continue;
|
| 1231 |
+
}
|
| 1232 |
+
|
| 1233 |
+
// 1. Resolve Provider (skipped entirely for category-only updates)
|
| 1234 |
+
let provider = vendorProvider;
|
| 1235 |
+
if (!isVendor && !isCategoryOnlyUpdate) {
|
| 1236 |
+
const pIdentifier = row.provider
|
| 1237 |
+
? row.provider.toString().trim()
|
| 1238 |
+
: '';
|
| 1239 |
+
let foundProvider = null;
|
| 1240 |
+
|
| 1241 |
+
if (/^\d+$/.test(pIdentifier)) {
|
| 1242 |
+
foundProvider = await Provider.findOne({ id: Number(pIdentifier) });
|
| 1243 |
+
}
|
| 1244 |
+
|
| 1245 |
+
if (!foundProvider && mongoose.Types.ObjectId.isValid(pIdentifier)) {
|
| 1246 |
+
foundProvider = await Provider.findById(pIdentifier);
|
| 1247 |
+
}
|
| 1248 |
+
|
| 1249 |
+
if (!foundProvider) {
|
| 1250 |
+
foundProvider = await Provider.findOne({
|
| 1251 |
+
$or: [{ storeName: pIdentifier }, { name: pIdentifier }],
|
| 1252 |
+
});
|
| 1253 |
+
}
|
| 1254 |
+
|
| 1255 |
+
provider = foundProvider;
|
| 1256 |
+
|
| 1257 |
+
if (!provider) {
|
| 1258 |
+
errors.push({
|
| 1259 |
+
row: index + 2,
|
| 1260 |
+
message: `Invalid provider: ${row.provider}`,
|
| 1261 |
+
name: row.nameAr || 'Unknown',
|
| 1262 |
+
});
|
| 1263 |
+
// eslint-disable-next-line no-continue
|
| 1264 |
+
continue;
|
| 1265 |
+
}
|
| 1266 |
+
}
|
| 1267 |
+
|
| 1268 |
+
// Validation: Required fields only for NEW products or full-row updates
|
| 1269 |
+
// For partial/category-only updates, barcode alone is enough to find the product
|
| 1270 |
+
const hasMissingFields =
|
| 1271 |
+
isCategoryOnlyUpdate || isPriceOnlyUpdate
|
| 1272 |
+
? false // skip full validation for partial updates
|
| 1273 |
+
: isVendor
|
| 1274 |
+
? !row.barCode || !row.nameAr || !row.brand || !row.category
|
| 1275 |
+
: !row.barCode ||
|
| 1276 |
+
!row.nameAr ||
|
| 1277 |
+
!row.brand ||
|
| 1278 |
+
!row.category ||
|
| 1279 |
+
!row.provider;
|
| 1280 |
+
|
| 1281 |
+
if (hasMissingFields) {
|
| 1282 |
+
errors.push({
|
| 1283 |
+
row: index + 2,
|
| 1284 |
+
message: isVendor
|
| 1285 |
+
? 'Missing required fields: Barcode, Name, Brand, or Category'
|
| 1286 |
+
: 'Missing required fields: Barcode, Name, Brand, Category, or Provider',
|
| 1287 |
+
});
|
| 1288 |
+
// eslint-disable-next-line no-continue
|
| 1289 |
+
continue;
|
| 1290 |
+
}
|
| 1291 |
+
|
| 1292 |
+
// Search for existing product(s) by barcode.
|
| 1293 |
+
// For category-only updates: find ALL products with this barcode (across all providers)
|
| 1294 |
+
// For full updates: find by barcode+provider to enforce uniqueness per provider
|
| 1295 |
+
// eslint-disable-next-line no-await-in-loop
|
| 1296 |
+
const existingProducts = isBroadBarcodeUpdate
|
| 1297 |
+
? await Product.find({ barCode: row.barCode.toString().trim() })
|
| 1298 |
+
: null;
|
| 1299 |
+
|
| 1300 |
+
// eslint-disable-next-line no-await-in-loop
|
| 1301 |
+
const existingProduct = isBroadBarcodeUpdate
|
| 1302 |
+
? existingProducts && existingProducts.length > 0
|
| 1303 |
+
? existingProducts[0]
|
| 1304 |
+
: null
|
| 1305 |
+
: await Product.findOne({
|
| 1306 |
+
barCode: row.barCode,
|
| 1307 |
+
provider: provider._id,
|
| 1308 |
+
});
|
| 1309 |
+
|
| 1310 |
+
// (Redundant but safe: If vendor, they only see products for their own provider anyway due to search above)
|
| 1311 |
+
if (
|
| 1312 |
+
existingProduct &&
|
| 1313 |
+
isVendor &&
|
| 1314 |
+
!existingProduct.provider.equals(vendorProvider._id)
|
| 1315 |
+
) {
|
| 1316 |
+
errors.push({
|
| 1317 |
+
row: index + 2,
|
| 1318 |
+
message:
|
| 1319 |
+
'You do not have permission to update this product (belongs to another provider)',
|
| 1320 |
+
name: row.nameAr,
|
| 1321 |
+
});
|
| 1322 |
+
// eslint-disable-next-line no-continue
|
| 1323 |
+
continue;
|
| 1324 |
+
}
|
| 1325 |
+
|
| 1326 |
+
if (existingProduct) {
|
| 1327 |
+
if (isPriceOnlyUpdate) {
|
| 1328 |
+
const normalizedBarcode = row.barCode.toString().trim();
|
| 1329 |
+
const nextPurchasePrice =
|
| 1330 |
+
row.purchasePrice !== undefined && row.purchasePrice !== null
|
| 1331 |
+
? Number(row.purchasePrice) || 0
|
| 1332 |
+
: undefined;
|
| 1333 |
+
const nextCostPrice =
|
| 1334 |
+
row.costPrice !== undefined && row.costPrice !== null
|
| 1335 |
+
? Number(row.costPrice) || 0
|
| 1336 |
+
: undefined;
|
| 1337 |
+
const nextPrice =
|
| 1338 |
+
row.price !== undefined && row.price !== null
|
| 1339 |
+
? Number(row.price) || 0
|
| 1340 |
+
: undefined;
|
| 1341 |
+
|
| 1342 |
+
if (isPreview) {
|
| 1343 |
+
updatedProducts.push({
|
| 1344 |
+
barCode: normalizedBarcode,
|
| 1345 |
+
changes: {
|
| 1346 |
+
...(nextPurchasePrice !== undefined
|
| 1347 |
+
? {
|
| 1348 |
+
purchasePrice: {
|
| 1349 |
+
old: existingProduct.purchasePrice,
|
| 1350 |
+
new: nextPurchasePrice,
|
| 1351 |
+
},
|
| 1352 |
+
}
|
| 1353 |
+
: {}),
|
| 1354 |
+
...(nextCostPrice !== undefined
|
| 1355 |
+
? {
|
| 1356 |
+
costPrice: {
|
| 1357 |
+
old: existingProduct.costPrice,
|
| 1358 |
+
new: nextCostPrice,
|
| 1359 |
+
},
|
| 1360 |
+
}
|
| 1361 |
+
: {}),
|
| 1362 |
+
...(nextPrice !== undefined
|
| 1363 |
+
? {
|
| 1364 |
+
price: {
|
| 1365 |
+
old: existingProduct.price,
|
| 1366 |
+
new: nextPrice,
|
| 1367 |
+
},
|
| 1368 |
+
}
|
| 1369 |
+
: {}),
|
| 1370 |
+
},
|
| 1371 |
+
providerName:
|
| 1372 |
+
(provider && provider.storeName) || 'All Providers',
|
| 1373 |
+
});
|
| 1374 |
+
} else {
|
| 1375 |
+
const updateFields = {};
|
| 1376 |
+
if (nextPurchasePrice !== undefined)
|
| 1377 |
+
updateFields.purchasePrice = nextPurchasePrice;
|
| 1378 |
+
if (nextCostPrice !== undefined)
|
| 1379 |
+
updateFields.costPrice = nextCostPrice;
|
| 1380 |
+
if (nextPrice !== undefined) updateFields.price = nextPrice;
|
| 1381 |
+
|
| 1382 |
+
if (isBroadBarcodeUpdate) {
|
| 1383 |
+
// eslint-disable-next-line no-await-in-loop
|
| 1384 |
+
await Product.updateMany(
|
| 1385 |
+
{ barCode: normalizedBarcode },
|
| 1386 |
+
{ $set: updateFields },
|
| 1387 |
+
);
|
| 1388 |
+
} else {
|
| 1389 |
+
// eslint-disable-next-line no-await-in-loop
|
| 1390 |
+
await Product.findByIdAndUpdate(existingProduct._id, {
|
| 1391 |
+
$set: updateFields,
|
| 1392 |
+
});
|
| 1393 |
+
}
|
| 1394 |
+
|
| 1395 |
+
updatedProducts.push(normalizedBarcode);
|
| 1396 |
+
}
|
| 1397 |
+
|
| 1398 |
+
// eslint-disable-next-line no-continue
|
| 1399 |
+
continue;
|
| 1400 |
+
}
|
| 1401 |
+
|
| 1402 |
+
// EXISTING PRODUCT: Update all fields
|
| 1403 |
+
// SMART CATEGORY RESOLUTION
|
| 1404 |
+
const rawCategoryValue = (row.category || '').toString().trim();
|
| 1405 |
+
const categoryIdentifiers = rawCategoryValue
|
| 1406 |
+
? [
|
| 1407 |
+
...new Set(
|
| 1408 |
+
rawCategoryValue
|
| 1409 |
+
.split(',')
|
| 1410 |
+
.map((val) => val.trim())
|
| 1411 |
+
.filter(Boolean),
|
| 1412 |
+
),
|
| 1413 |
+
]
|
| 1414 |
+
: [];
|
| 1415 |
+
|
| 1416 |
+
const categoryObjectIds = [];
|
| 1417 |
+
const categoryNamesForPreview = [];
|
| 1418 |
+
|
| 1419 |
+
for (const identifier of categoryIdentifiers) {
|
| 1420 |
+
let foundCategory = null;
|
| 1421 |
+
|
| 1422 |
+
// 1. Try Numeric ID
|
| 1423 |
+
if (/^\d+$/.test(identifier)) {
|
| 1424 |
+
foundCategory = await Category.findOne({
|
| 1425 |
+
id: Number(identifier),
|
| 1426 |
+
});
|
| 1427 |
+
}
|
| 1428 |
+
|
| 1429 |
+
// 2. Try ObjectId
|
| 1430 |
+
if (!foundCategory && mongoose.Types.ObjectId.isValid(identifier)) {
|
| 1431 |
+
foundCategory = await Category.findById(identifier);
|
| 1432 |
+
}
|
| 1433 |
+
|
| 1434 |
+
// 3. Try Name (Ar/En) or Slug
|
| 1435 |
+
if (!foundCategory) {
|
| 1436 |
+
foundCategory = await Category.findOne({
|
| 1437 |
+
$or: [
|
| 1438 |
+
{ nameAr: identifier },
|
| 1439 |
+
{ nameEn: identifier },
|
| 1440 |
+
{ slug: identifier },
|
| 1441 |
+
],
|
| 1442 |
+
});
|
| 1443 |
+
}
|
| 1444 |
+
|
| 1445 |
+
if (foundCategory) {
|
| 1446 |
+
categoryObjectIds.push(foundCategory._id);
|
| 1447 |
+
categoryNamesForPreview.push(foundCategory.nameAr);
|
| 1448 |
+
}
|
| 1449 |
+
}
|
| 1450 |
+
|
| 1451 |
+
// Validation: Ensure at least one category is found
|
| 1452 |
+
if (
|
| 1453 |
+
categoryIdentifiers.length > 0 &&
|
| 1454 |
+
categoryObjectIds.length === 0
|
| 1455 |
+
) {
|
| 1456 |
+
errors.push({
|
| 1457 |
+
row: index + 2,
|
| 1458 |
+
message: `None of the provided category identifiers were found: ${row.category}`,
|
| 1459 |
+
name: row.nameAr,
|
| 1460 |
+
});
|
| 1461 |
+
// eslint-disable-next-line no-continue
|
| 1462 |
+
continue;
|
| 1463 |
+
}
|
| 1464 |
+
|
| 1465 |
+
// SMART BRAND RESOLUTION
|
| 1466 |
+
let brandName = row.brand;
|
| 1467 |
+
if (row.brand) {
|
| 1468 |
+
let foundBrand = null;
|
| 1469 |
+
const bIdentifier = row.brand.toString().trim();
|
| 1470 |
+
|
| 1471 |
+
if (/^\d+$/.test(bIdentifier)) {
|
| 1472 |
+
foundBrand = await Brand.findOne({ id: Number(bIdentifier) });
|
| 1473 |
+
}
|
| 1474 |
+
|
| 1475 |
+
if (!foundBrand && mongoose.Types.ObjectId.isValid(bIdentifier)) {
|
| 1476 |
+
foundBrand = await Brand.findById(bIdentifier);
|
| 1477 |
+
}
|
| 1478 |
+
|
| 1479 |
+
if (!foundBrand) {
|
| 1480 |
+
foundBrand = await Brand.findOne({
|
| 1481 |
+
$or: [
|
| 1482 |
+
{ nameEn: bIdentifier },
|
| 1483 |
+
{ nameAr: bIdentifier },
|
| 1484 |
+
{ slug: bIdentifier },
|
| 1485 |
+
],
|
| 1486 |
+
});
|
| 1487 |
+
}
|
| 1488 |
+
|
| 1489 |
+
if (foundBrand) brandName = foundBrand.nameEn;
|
| 1490 |
+
}
|
| 1491 |
+
|
| 1492 |
+
// Handle Image Uploads to Cloudinary (ONLY if not preview)
|
| 1493 |
+
let finalImageCover = row.imageCover || existingProduct.imageCover;
|
| 1494 |
+
let finalImages = row.images
|
| 1495 |
+
? row.images.split(',')
|
| 1496 |
+
: existingProduct.images || [];
|
| 1497 |
+
|
| 1498 |
+
if (!isPreview) {
|
| 1499 |
+
if (row.imageCover) {
|
| 1500 |
+
// eslint-disable-next-line no-await-in-loop
|
| 1501 |
+
finalImageCover = await uploadExternalImage(
|
| 1502 |
+
row.imageCover,
|
| 1503 |
+
existingProduct._id,
|
| 1504 |
+
true,
|
| 1505 |
+
);
|
| 1506 |
+
}
|
| 1507 |
+
|
| 1508 |
+
if (row.images) {
|
| 1509 |
+
const imgArray = row.images.split(',');
|
| 1510 |
+
// eslint-disable-next-line no-await-in-loop
|
| 1511 |
+
finalImages = await Promise.all(
|
| 1512 |
+
imgArray.map((img) =>
|
| 1513 |
+
uploadExternalImage(img.trim(), existingProduct._id, false),
|
| 1514 |
+
),
|
| 1515 |
+
);
|
| 1516 |
+
|
| 1517 |
+
finalImages = finalImages.filter(Boolean);
|
| 1518 |
+
|
| 1519 |
+
// Delete any old extra images that are no longer in the updated list
|
| 1520 |
+
if (finalImages.length > 0) {
|
| 1521 |
+
const oldImages = existingProduct.images || [];
|
| 1522 |
+
for (const oldImg of oldImages) {
|
| 1523 |
+
if (oldImg && !finalImages.includes(oldImg)) {
|
| 1524 |
+
// eslint-disable-next-line no-await-in-loop
|
| 1525 |
+
await deleteCloudinaryImage(oldImg);
|
| 1526 |
+
}
|
| 1527 |
+
}
|
| 1528 |
+
}
|
| 1529 |
+
}
|
| 1530 |
+
|
| 1531 |
+
// Keep updates resilient: use first valid gallery image, otherwise keep old cover.
|
| 1532 |
+
if (!finalImageCover && finalImages.length > 0) {
|
| 1533 |
+
[finalImageCover] = finalImages;
|
| 1534 |
+
}
|
| 1535 |
+
if (!finalImageCover) {
|
| 1536 |
+
// eslint-disable-next-line no-await-in-loop
|
| 1537 |
+
const existingCoverIsReachable = await isImageUrlReachable(
|
| 1538 |
+
existingProduct.imageCover,
|
| 1539 |
+
);
|
| 1540 |
+
finalImageCover = existingCoverIsReachable
|
| 1541 |
+
? existingProduct.imageCover
|
| 1542 |
+
: IMPORT_FALLBACK_COVER_IMAGE;
|
| 1543 |
+
}
|
| 1544 |
+
|
| 1545 |
+
// If cover changed, cleanup old cover on our Cloudinary cloud.
|
| 1546 |
+
if (
|
| 1547 |
+
finalImageCover &&
|
| 1548 |
+
existingProduct.imageCover &&
|
| 1549 |
+
existingProduct.imageCover !== finalImageCover
|
| 1550 |
+
) {
|
| 1551 |
+
// eslint-disable-next-line no-await-in-loop
|
| 1552 |
+
await deleteCloudinaryImage(existingProduct.imageCover);
|
| 1553 |
+
}
|
| 1554 |
+
}
|
| 1555 |
+
|
| 1556 |
+
// PARTIAL UPDATE: Only include fields that are present in the Excel row
|
| 1557 |
+
const updateData = {};
|
| 1558 |
+
|
| 1559 |
+
// Category is always updated when provided (required for category-only mode)
|
| 1560 |
+
if (categoryObjectIds.length > 0)
|
| 1561 |
+
updateData.category = categoryObjectIds;
|
| 1562 |
+
|
| 1563 |
+
// Only update other fields if they are actually present in the row
|
| 1564 |
+
if (
|
| 1565 |
+
row.nameEn !== undefined &&
|
| 1566 |
+
row.nameEn !== null &&
|
| 1567 |
+
row.nameEn !== ''
|
| 1568 |
+
)
|
| 1569 |
+
updateData.nameEn = row.nameEn;
|
| 1570 |
+
if (
|
| 1571 |
+
row.nameAr !== undefined &&
|
| 1572 |
+
row.nameAr !== null &&
|
| 1573 |
+
row.nameAr !== ''
|
| 1574 |
+
)
|
| 1575 |
+
updateData.nameAr = row.nameAr;
|
| 1576 |
+
if (row.descriptionEn)
|
| 1577 |
+
updateData.descriptionEn = row.descriptionEn.split(',');
|
| 1578 |
+
if (row.descriptionAr)
|
| 1579 |
+
updateData.descriptionAr = row.descriptionAr.split(',');
|
| 1580 |
+
if (brandName && row.brand) updateData.brand = brandName;
|
| 1581 |
+
if (provider) updateData.provider = provider._id;
|
| 1582 |
+
if (row.stock !== undefined && row.stock !== null && row.stock !== '')
|
| 1583 |
+
updateData.stock = Number(row.stock) || 0;
|
| 1584 |
+
if (
|
| 1585 |
+
row.isFeatured !== undefined &&
|
| 1586 |
+
row.isFeatured !== null &&
|
| 1587 |
+
row.isFeatured !== ''
|
| 1588 |
+
)
|
| 1589 |
+
updateData.isFeatured = Boolean(Number(row.isFeatured));
|
| 1590 |
+
if (row.price !== undefined && row.price !== null && row.price !== '')
|
| 1591 |
+
updateData.price = Number(row.price) || 0;
|
| 1592 |
+
if (
|
| 1593 |
+
row.purchasePrice !== undefined &&
|
| 1594 |
+
row.purchasePrice !== null &&
|
| 1595 |
+
row.purchasePrice !== ''
|
| 1596 |
+
)
|
| 1597 |
+
updateData.purchasePrice = Number(row.purchasePrice) || 0;
|
| 1598 |
+
if (
|
| 1599 |
+
row.salePrice !== undefined &&
|
| 1600 |
+
row.salePrice !== null &&
|
| 1601 |
+
row.salePrice !== ''
|
| 1602 |
+
)
|
| 1603 |
+
updateData.salePrice = Number(row.salePrice) || 0;
|
| 1604 |
+
if (row.ratingsAverage !== undefined && row.ratingsAverage !== '')
|
| 1605 |
+
updateData.ratingsAverage = Number(row.ratingsAverage) || 0;
|
| 1606 |
+
if (row.ratingsQuantity !== undefined && row.ratingsQuantity !== '')
|
| 1607 |
+
updateData.ratingsQuantity = Number(row.ratingsQuantity) || 0;
|
| 1608 |
+
if (finalImageCover) updateData.imageCover = finalImageCover;
|
| 1609 |
+
if (finalImages && finalImages.length > 0)
|
| 1610 |
+
updateData.images = finalImages;
|
| 1611 |
+
|
| 1612 |
+
if (isPreview) {
|
| 1613 |
+
// Calculate changes for preview — only show fields that are actually in updateData
|
| 1614 |
+
const changes = {};
|
| 1615 |
+
if (
|
| 1616 |
+
'nameAr' in updateData &&
|
| 1617 |
+
existingProduct.nameAr !== updateData.nameAr
|
| 1618 |
+
)
|
| 1619 |
+
changes.nameAr = {
|
| 1620 |
+
old: existingProduct.nameAr,
|
| 1621 |
+
new: updateData.nameAr,
|
| 1622 |
+
};
|
| 1623 |
+
if (
|
| 1624 |
+
'nameEn' in updateData &&
|
| 1625 |
+
existingProduct.nameEn !== updateData.nameEn
|
| 1626 |
+
)
|
| 1627 |
+
changes.nameEn = {
|
| 1628 |
+
old: existingProduct.nameEn,
|
| 1629 |
+
new: updateData.nameEn,
|
| 1630 |
+
};
|
| 1631 |
+
if (
|
| 1632 |
+
'price' in updateData &&
|
| 1633 |
+
existingProduct.price !== updateData.price
|
| 1634 |
+
)
|
| 1635 |
+
changes.price = {
|
| 1636 |
+
old: existingProduct.price,
|
| 1637 |
+
new: updateData.price,
|
| 1638 |
+
};
|
| 1639 |
+
if (
|
| 1640 |
+
'purchasePrice' in updateData &&
|
| 1641 |
+
existingProduct.purchasePrice !== updateData.purchasePrice
|
| 1642 |
+
)
|
| 1643 |
+
changes.purchasePrice = {
|
| 1644 |
+
old: existingProduct.purchasePrice,
|
| 1645 |
+
new: updateData.purchasePrice,
|
| 1646 |
+
};
|
| 1647 |
+
if (
|
| 1648 |
+
'salePrice' in updateData &&
|
| 1649 |
+
existingProduct.salePrice !== updateData.salePrice
|
| 1650 |
+
)
|
| 1651 |
+
changes.salePrice = {
|
| 1652 |
+
old: existingProduct.salePrice,
|
| 1653 |
+
new: updateData.salePrice,
|
| 1654 |
+
};
|
| 1655 |
+
if (
|
| 1656 |
+
'stock' in updateData &&
|
| 1657 |
+
existingProduct.stock !== updateData.stock
|
| 1658 |
+
)
|
| 1659 |
+
changes.stock = {
|
| 1660 |
+
old: existingProduct.stock,
|
| 1661 |
+
new: updateData.stock,
|
| 1662 |
+
};
|
| 1663 |
+
if (
|
| 1664 |
+
'brand' in updateData &&
|
| 1665 |
+
existingProduct.brand !== updateData.brand
|
| 1666 |
+
)
|
| 1667 |
+
changes.brand = {
|
| 1668 |
+
old: existingProduct.brand,
|
| 1669 |
+
new: updateData.brand,
|
| 1670 |
+
};
|
| 1671 |
+
if ('category' in updateData)
|
| 1672 |
+
changes.category = {
|
| 1673 |
+
old: '(previous)',
|
| 1674 |
+
new: categoryNamesForPreview.join(', '),
|
| 1675 |
+
};
|
| 1676 |
+
|
| 1677 |
+
updatedProducts.push({
|
| 1678 |
+
nameAr: updateData.nameAr || existingProduct.nameAr,
|
| 1679 |
+
barCode: row.barCode,
|
| 1680 |
+
changes,
|
| 1681 |
+
categoryNames: categoryNamesForPreview.join(', '),
|
| 1682 |
+
providerName: (provider && provider.storeName) || 'All Providers',
|
| 1683 |
+
});
|
| 1684 |
+
} else if (isCategoryOnlyUpdate) {
|
| 1685 |
+
// CATEGORY-ONLY: update ALL products with this barcode across all providers
|
| 1686 |
+
// eslint-disable-next-line no-await-in-loop
|
| 1687 |
+
await Product.updateMany(
|
| 1688 |
+
{ barCode: row.barCode.toString().trim() },
|
| 1689 |
+
{ $set: { category: categoryObjectIds } },
|
| 1690 |
+
);
|
| 1691 |
+
updatedProducts.push(row.barCode);
|
| 1692 |
+
} else {
|
| 1693 |
+
// Update single product (full update path)
|
| 1694 |
+
// eslint-disable-next-line no-await-in-loop
|
| 1695 |
+
const updatedProd = await Product.findByIdAndUpdate(
|
| 1696 |
+
existingProduct._id,
|
| 1697 |
+
{ $set: updateData },
|
| 1698 |
+
{ new: true },
|
| 1699 |
+
);
|
| 1700 |
+
|
| 1701 |
+
// Check if restocked
|
| 1702 |
+
if (
|
| 1703 |
+
updateData.stock > 0 &&
|
| 1704 |
+
(!existingProduct.stock || existingProduct.stock <= 0)
|
| 1705 |
+
) {
|
| 1706 |
+
notifyPastBuyersProductBackInStock(updatedProd).catch((err) =>
|
| 1707 |
+
console.error(
|
| 1708 |
+
'Failed to send back-in-stock notification during import:',
|
| 1709 |
+
err.message,
|
| 1710 |
+
),
|
| 1711 |
+
);
|
| 1712 |
+
}
|
| 1713 |
+
|
| 1714 |
+
updatedProducts.push(existingProduct.barCode);
|
| 1715 |
+
}
|
| 1716 |
+
} else {
|
| 1717 |
+
if (isPriceOnlyUpdate) {
|
| 1718 |
+
missingProducts.push({
|
| 1719 |
+
row: index + 2,
|
| 1720 |
+
barCode: row.barCode.toString().trim(),
|
| 1721 |
+
provider: row.provider ? row.provider.toString().trim() : '',
|
| 1722 |
+
purchasePrice:
|
| 1723 |
+
row.purchasePrice !== undefined && row.purchasePrice !== null
|
| 1724 |
+
? Number(row.purchasePrice) || 0
|
| 1725 |
+
: undefined,
|
| 1726 |
+
costPrice:
|
| 1727 |
+
row.costPrice !== undefined && row.costPrice !== null
|
| 1728 |
+
? Number(row.costPrice) || 0
|
| 1729 |
+
: undefined,
|
| 1730 |
+
price:
|
| 1731 |
+
row.price !== undefined && row.price !== null
|
| 1732 |
+
? Number(row.price) || 0
|
| 1733 |
+
: undefined,
|
| 1734 |
+
});
|
| 1735 |
+
// eslint-disable-next-line no-continue
|
| 1736 |
+
continue;
|
| 1737 |
+
}
|
| 1738 |
+
|
| 1739 |
+
// NEW PRODUCT: Create
|
| 1740 |
+
// SMART CATEGORY RESOLUTION (NEW PRODUCT)
|
| 1741 |
+
const rawCategoryValueNew = (row.category || '').toString().trim();
|
| 1742 |
+
const categoryIdentifiersNew = rawCategoryValueNew
|
| 1743 |
+
? [
|
| 1744 |
+
...new Set(
|
| 1745 |
+
rawCategoryValueNew
|
| 1746 |
+
.split(',')
|
| 1747 |
+
.map((val) => val.trim())
|
| 1748 |
+
.filter(Boolean),
|
| 1749 |
+
),
|
| 1750 |
+
]
|
| 1751 |
+
: [];
|
| 1752 |
+
|
| 1753 |
+
const categoryObjectIdsNew = [];
|
| 1754 |
+
const categoryNamesForPreviewNew = [];
|
| 1755 |
+
|
| 1756 |
+
for (const identifier of categoryIdentifiersNew) {
|
| 1757 |
+
let foundCategory = null;
|
| 1758 |
+
|
| 1759 |
+
if (/^\d+$/.test(identifier)) {
|
| 1760 |
+
foundCategory = await Category.findOne({
|
| 1761 |
+
id: Number(identifier),
|
| 1762 |
+
});
|
| 1763 |
+
}
|
| 1764 |
+
|
| 1765 |
+
if (!foundCategory && mongoose.Types.ObjectId.isValid(identifier)) {
|
| 1766 |
+
foundCategory = await Category.findById(identifier);
|
| 1767 |
+
}
|
| 1768 |
+
|
| 1769 |
+
if (!foundCategory) {
|
| 1770 |
+
foundCategory = await Category.findOne({
|
| 1771 |
+
$or: [
|
| 1772 |
+
{ nameAr: identifier },
|
| 1773 |
+
{ nameEn: identifier },
|
| 1774 |
+
{ slug: identifier },
|
| 1775 |
+
],
|
| 1776 |
+
});
|
| 1777 |
+
}
|
| 1778 |
+
|
| 1779 |
+
if (foundCategory) {
|
| 1780 |
+
categoryObjectIdsNew.push(foundCategory._id);
|
| 1781 |
+
categoryNamesForPreviewNew.push(foundCategory.nameAr);
|
| 1782 |
+
}
|
| 1783 |
+
}
|
| 1784 |
+
|
| 1785 |
+
if (
|
| 1786 |
+
categoryIdentifiersNew.length > 0 &&
|
| 1787 |
+
categoryObjectIdsNew.length === 0
|
| 1788 |
+
) {
|
| 1789 |
+
errors.push({
|
| 1790 |
+
row: index + 2,
|
| 1791 |
+
message: `None of the provided category identifiers were found: ${row.category}`,
|
| 1792 |
+
name: row.nameAr,
|
| 1793 |
+
});
|
| 1794 |
+
// eslint-disable-next-line no-continue
|
| 1795 |
+
continue;
|
| 1796 |
+
}
|
| 1797 |
+
|
| 1798 |
+
// SMART BRAND RESOLUTION (NEW PRODUCT)
|
| 1799 |
+
let brandNameForNew = row.brand;
|
| 1800 |
+
if (row.brand) {
|
| 1801 |
+
let foundBrand = null;
|
| 1802 |
+
const bIdentifier = row.brand.toString().trim();
|
| 1803 |
+
|
| 1804 |
+
if (/^\d+$/.test(bIdentifier)) {
|
| 1805 |
+
foundBrand = await Brand.findOne({ id: Number(bIdentifier) });
|
| 1806 |
+
}
|
| 1807 |
+
|
| 1808 |
+
if (!foundBrand && mongoose.Types.ObjectId.isValid(bIdentifier)) {
|
| 1809 |
+
foundBrand = await Brand.findById(bIdentifier);
|
| 1810 |
+
}
|
| 1811 |
+
|
| 1812 |
+
if (!foundBrand) {
|
| 1813 |
+
foundBrand = await Brand.findOne({
|
| 1814 |
+
$or: [
|
| 1815 |
+
{ nameEn: bIdentifier },
|
| 1816 |
+
{ nameAr: bIdentifier },
|
| 1817 |
+
{ slug: bIdentifier },
|
| 1818 |
+
],
|
| 1819 |
+
});
|
| 1820 |
+
}
|
| 1821 |
+
|
| 1822 |
+
if (foundBrand) brandNameForNew = foundBrand.nameEn;
|
| 1823 |
+
}
|
| 1824 |
+
|
| 1825 |
+
// Generate potential ID for new product to use in folder name
|
| 1826 |
+
const newProductId = new mongoose.Types.ObjectId();
|
| 1827 |
+
|
| 1828 |
+
// Handle Image Uploads to Cloudinary (ONLY if not preview)
|
| 1829 |
+
let finalImageCoverNew = row.imageCover;
|
| 1830 |
+
let finalImagesNew = row.images ? row.images.split(',') : [];
|
| 1831 |
+
|
| 1832 |
+
if (!isPreview) {
|
| 1833 |
+
if (row.imageCover) {
|
| 1834 |
+
// eslint-disable-next-line no-await-in-loop
|
| 1835 |
+
finalImageCoverNew = await uploadExternalImage(
|
| 1836 |
+
row.imageCover,
|
| 1837 |
+
newProductId,
|
| 1838 |
+
true,
|
| 1839 |
+
);
|
| 1840 |
+
}
|
| 1841 |
+
|
| 1842 |
+
if (row.images) {
|
| 1843 |
+
const imgArray = row.images.split(',');
|
| 1844 |
+
// eslint-disable-next-line no-await-in-loop
|
| 1845 |
+
finalImagesNew = await Promise.all(
|
| 1846 |
+
imgArray.map((img) =>
|
| 1847 |
+
uploadExternalImage(img.trim(), newProductId, false),
|
| 1848 |
+
),
|
| 1849 |
+
);
|
| 1850 |
+
}
|
| 1851 |
+
|
| 1852 |
+
// Ensure new products always have a valid cover image to satisfy schema validation.
|
| 1853 |
+
finalImagesNew = finalImagesNew.filter(Boolean);
|
| 1854 |
+
if (!finalImageCoverNew && finalImagesNew.length > 0) {
|
| 1855 |
+
[finalImageCoverNew] = finalImagesNew;
|
| 1856 |
+
}
|
| 1857 |
+
if (!finalImageCoverNew) {
|
| 1858 |
+
finalImageCoverNew = IMPORT_FALLBACK_COVER_IMAGE;
|
| 1859 |
+
}
|
| 1860 |
+
}
|
| 1861 |
+
|
| 1862 |
+
const productData = {
|
| 1863 |
+
_id: newProductId,
|
| 1864 |
+
nameEn: row.nameEn,
|
| 1865 |
+
nameAr: row.nameAr,
|
| 1866 |
+
descriptionEn: row.descriptionEn
|
| 1867 |
+
? row.descriptionEn.split(',')
|
| 1868 |
+
: [],
|
| 1869 |
+
descriptionAr: row.descriptionAr
|
| 1870 |
+
? row.descriptionAr.split(',')
|
| 1871 |
+
: [],
|
| 1872 |
+
barCode: row.barCode,
|
| 1873 |
+
brand: brandNameForNew,
|
| 1874 |
+
category: categoryObjectIdsNew,
|
| 1875 |
+
provider: provider._id,
|
| 1876 |
+
stock: Number(row.stock) || 0,
|
| 1877 |
+
isFeatured: Boolean(Number(row.isFeatured)),
|
| 1878 |
+
price: Number(row.price) || 0,
|
| 1879 |
+
purchasePrice: Number(row.purchasePrice) || 0,
|
| 1880 |
+
salePrice: Number(row.salePrice) || 0,
|
| 1881 |
+
ratingsAverage: Number(row.ratingsAverage) || 0,
|
| 1882 |
+
ratingsQuantity: Number(row.ratingsQuantity) || 0,
|
| 1883 |
+
imageCover: finalImageCoverNew,
|
| 1884 |
+
images: finalImagesNew,
|
| 1885 |
+
};
|
| 1886 |
+
|
| 1887 |
+
if (isPreview) {
|
| 1888 |
+
newProducts.push({
|
| 1889 |
+
...productData,
|
| 1890 |
+
categoryNames: categoryNamesForPreviewNew.join(', '),
|
| 1891 |
+
providerName: provider.storeName,
|
| 1892 |
+
});
|
| 1893 |
+
} else {
|
| 1894 |
+
// eslint-disable-next-line no-await-in-loop
|
| 1895 |
+
await Product.create(productData);
|
| 1896 |
+
newProducts.push(row.barCode);
|
| 1897 |
+
}
|
| 1898 |
+
}
|
| 1899 |
+
} catch (err) {
|
| 1900 |
+
errors.push({
|
| 1901 |
+
row: index + 2,
|
| 1902 |
+
message: err.message,
|
| 1903 |
+
name: row.nameAr || 'Unknown',
|
| 1904 |
+
});
|
| 1905 |
+
}
|
| 1906 |
+
}
|
| 1907 |
+
|
| 1908 |
+
// No file cleanup needed - using memory storage
|
| 1909 |
+
|
| 1910 |
+
const { missingExport } = req.query;
|
| 1911 |
+
const shouldExportMissing =
|
| 1912 |
+
missingExport === 'true' && missingProducts.length > 0;
|
| 1913 |
+
|
| 1914 |
+
if (shouldExportMissing) {
|
| 1915 |
+
const templateRows = missingProducts.map((item) => ({
|
| 1916 |
+
barCode: item.barCode || '',
|
| 1917 |
+
provider: item.provider || '',
|
| 1918 |
+
purchasePrice:
|
| 1919 |
+
item.purchasePrice !== undefined ? item.purchasePrice : '',
|
| 1920 |
+
costPrice: item.costPrice !== undefined ? item.costPrice : '',
|
| 1921 |
+
price: item.price !== undefined ? item.price : '',
|
| 1922 |
+
nameAr: '',
|
| 1923 |
+
nameEn: '',
|
| 1924 |
+
brand: '',
|
| 1925 |
+
category: '',
|
| 1926 |
+
stock: '',
|
| 1927 |
+
imageCover: '',
|
| 1928 |
+
images: '',
|
| 1929 |
+
}));
|
| 1930 |
+
|
| 1931 |
+
const workbook = xlsx.utils.book_new();
|
| 1932 |
+
const worksheet = xlsx.utils.json_to_sheet(templateRows);
|
| 1933 |
+
xlsx.utils.book_append_sheet(workbook, worksheet, 'MissingProducts');
|
| 1934 |
+
|
| 1935 |
+
const buffer = xlsx.write(workbook, { type: 'buffer', bookType: 'xlsx' });
|
| 1936 |
+
|
| 1937 |
+
res.setHeader(
|
| 1938 |
+
'Content-Type',
|
| 1939 |
+
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
| 1940 |
+
);
|
| 1941 |
+
res.setHeader(
|
| 1942 |
+
'Content-Disposition',
|
| 1943 |
+
`attachment; filename=missing-products-${Date.now()}.xlsx`,
|
| 1944 |
+
);
|
| 1945 |
+
|
| 1946 |
+
res.send(buffer);
|
| 1947 |
+
return;
|
| 1948 |
+
}
|
| 1949 |
+
|
| 1950 |
+
res.status(200).json({
|
| 1951 |
+
status: 'success',
|
| 1952 |
+
mode: isPreview ? 'preview' : 'execute',
|
| 1953 |
+
summary: {
|
| 1954 |
+
newCount: newProducts.length,
|
| 1955 |
+
updatedCount: updatedProducts.length,
|
| 1956 |
+
missingCount: missingProducts.length,
|
| 1957 |
+
errorsCount: errors.length,
|
| 1958 |
+
},
|
| 1959 |
+
data: {
|
| 1960 |
+
newProducts,
|
| 1961 |
+
updatedProducts,
|
| 1962 |
+
missingProducts,
|
| 1963 |
+
errors,
|
| 1964 |
+
},
|
| 1965 |
+
});
|
| 1966 |
+
|
| 1967 |
+
// Notify AI service to reload cache after batch import
|
| 1968 |
+
if (!isPreview && (newProducts.length > 0 || updatedProducts.length > 0)) {
|
| 1969 |
+
triggerAiReload();
|
| 1970 |
+
}
|
| 1971 |
+
} catch (error) {
|
| 1972 |
+
// No file cleanup needed - using memory storage
|
| 1973 |
+
|
| 1974 |
+
console.error(error);
|
| 1975 |
+
res.status(500).json({
|
| 1976 |
+
status: 'error',
|
| 1977 |
+
message: 'Something went wrong',
|
| 1978 |
+
error: error.message,
|
| 1979 |
+
});
|
| 1980 |
+
}
|
| 1981 |
+
};
|
| 1982 |
+
|
| 1983 |
+
// Export products to Excel file
|
| 1984 |
+
exports.exportProducts = async (req, res) => {
|
| 1985 |
+
try {
|
| 1986 |
+
const isVendor = req.user.role === 'vendor';
|
| 1987 |
+
const query = {};
|
| 1988 |
+
|
| 1989 |
+
if (isVendor) {
|
| 1990 |
+
if (!req.user.provider) {
|
| 1991 |
+
return res.status(400).json({
|
| 1992 |
+
status: 'fail',
|
| 1993 |
+
message: 'Vendor user is not associated with any provider',
|
| 1994 |
+
});
|
| 1995 |
+
}
|
| 1996 |
+
query.provider = req.user.provider;
|
| 1997 |
+
}
|
| 1998 |
+
|
| 1999 |
+
// Add category filter if provided
|
| 2000 |
+
if (req.query.category) {
|
| 2001 |
+
query.category = req.query.category;
|
| 2002 |
+
}
|
| 2003 |
+
|
| 2004 |
+
// Add brand filter if provided
|
| 2005 |
+
if (req.query.brand) {
|
| 2006 |
+
query.brand = req.query.brand;
|
| 2007 |
+
}
|
| 2008 |
+
|
| 2009 |
+
// Fetch products with populated category and provider
|
| 2010 |
+
const products = await Product.find(query)
|
| 2011 |
+
.populate('category', 'id nameAr nameEn')
|
| 2012 |
+
.populate('provider', 'id storeName');
|
| 2013 |
+
|
| 2014 |
+
// Prepare data for Excel
|
| 2015 |
+
const excelData = products.map((product) => ({
|
| 2016 |
+
nameEn: product.nameEn || '',
|
| 2017 |
+
nameAr: product.nameAr || '',
|
| 2018 |
+
descriptionEn: product.descriptionEn
|
| 2019 |
+
? product.descriptionEn.join(',')
|
| 2020 |
+
: '',
|
| 2021 |
+
descriptionAr: product.descriptionAr
|
| 2022 |
+
? product.descriptionAr.join(',')
|
| 2023 |
+
: '',
|
| 2024 |
+
barCode: product.barCode || '',
|
| 2025 |
+
brand:
|
| 2026 |
+
typeof product.brand === 'string' &&
|
| 2027 |
+
/^[0-9a-fA-F]{24}$/.test(product.brand)
|
| 2028 |
+
? product.brand
|
| 2029 |
+
: product.brand || '',
|
| 2030 |
+
category:
|
| 2031 |
+
product.category && product.category.length > 0
|
| 2032 |
+
? product.category.map((cat) => cat.id).join(',')
|
| 2033 |
+
: '',
|
| 2034 |
+
provider: product.provider ? product.provider.id : '',
|
| 2035 |
+
stock: product.stock || 0,
|
| 2036 |
+
isFeatured: product.isFeatured ? 1 : 0,
|
| 2037 |
+
price: product.price || 0,
|
| 2038 |
+
purchasePrice: product.purchasePrice || 0,
|
| 2039 |
+
salePrice: product.salePrice || 0,
|
| 2040 |
+
ratingsAverage: product.ratingsAverage || 0,
|
| 2041 |
+
ratingsQuantity: product.ratingsQuantity || 0,
|
| 2042 |
+
imageCover: product.imageCover || '',
|
| 2043 |
+
images:
|
| 2044 |
+
product.images && product.images.length > 0
|
| 2045 |
+
? product.images.join(',')
|
| 2046 |
+
: '',
|
| 2047 |
+
}));
|
| 2048 |
+
|
| 2049 |
+
// Create workbook and worksheet
|
| 2050 |
+
const workbook = xlsx.utils.book_new();
|
| 2051 |
+
const worksheet = xlsx.utils.json_to_sheet(excelData);
|
| 2052 |
+
|
| 2053 |
+
// Add worksheet to workbook
|
| 2054 |
+
xlsx.utils.book_append_sheet(workbook, worksheet, 'Products');
|
| 2055 |
+
|
| 2056 |
+
// Generate buffer
|
| 2057 |
+
const buffer = xlsx.write(workbook, { type: 'buffer', bookType: 'xlsx' });
|
| 2058 |
+
|
| 2059 |
+
// Set headers for file download
|
| 2060 |
+
res.setHeader(
|
| 2061 |
+
'Content-Type',
|
| 2062 |
+
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
| 2063 |
+
);
|
| 2064 |
+
res.setHeader(
|
| 2065 |
+
'Content-Disposition',
|
| 2066 |
+
`attachment; filename=products-export-${Date.now()}.xlsx`,
|
| 2067 |
+
);
|
| 2068 |
+
|
| 2069 |
+
// Send file
|
| 2070 |
+
res.send(buffer);
|
| 2071 |
+
} catch (error) {
|
| 2072 |
+
console.error('Export error:', error);
|
| 2073 |
+
res.status(500).json({
|
| 2074 |
+
status: 'error',
|
| 2075 |
+
message: 'Failed to export products',
|
| 2076 |
+
error: error.message,
|
| 2077 |
+
});
|
| 2078 |
+
}
|
| 2079 |
+
};
|
| 2080 |
+
|
| 2081 |
+
// Generate Facebook Product Catalog XML Feed
|
| 2082 |
+
exports.getFacebookCatalog = async (req, res) => {
|
| 2083 |
+
try {
|
| 2084 |
+
// 1. Fetch products (limit to in-stock or all? Facebook usually wants all with availability status)
|
| 2085 |
+
const products = await Product.find()
|
| 2086 |
+
.populate('category', 'nameAr nameEn')
|
| 2087 |
+
.populate('brand', 'nameEn nameAr')
|
| 2088 |
+
.limit(2000); // Increased limit for Meta catalog
|
| 2089 |
+
|
| 2090 |
+
// 2. Format as RSS 2.0 (Facebook XML standard)
|
| 2091 |
+
const frontendUrl = (
|
| 2092 |
+
process.env.FRONTEND_URL || 'https://www.samoulla.com'
|
| 2093 |
+
).replace(/\/$/, '');
|
| 2094 |
+
|
| 2095 |
+
let xml = `<?xml version="1.0"?>
|
| 2096 |
+
<rss xmlns:g="http://base.google.com/ns/1.0" version="2.0">
|
| 2097 |
+
<channel>
|
| 2098 |
+
<title>Samoulla Product Catalog</title>
|
| 2099 |
+
<link>${frontendUrl}</link>
|
| 2100 |
+
<description>Quality tools, hardware, and equipment from Samoulla</description>`;
|
| 2101 |
+
|
| 2102 |
+
products.forEach((product) => {
|
| 2103 |
+
const id = product._id.toString();
|
| 2104 |
+
const title = (product.nameEn || product.nameAr || '').replace(
|
| 2105 |
+
/[&<>"']/g,
|
| 2106 |
+
(m) =>
|
| 2107 |
+
({
|
| 2108 |
+
'&': '&',
|
| 2109 |
+
'<': '<',
|
| 2110 |
+
'>': '>',
|
| 2111 |
+
'"': '"',
|
| 2112 |
+
"'": ''',
|
| 2113 |
+
})[m],
|
| 2114 |
+
);
|
| 2115 |
+
const description = (
|
| 2116 |
+
(product.descriptionEn && product.descriptionEn[0]) ||
|
| 2117 |
+
(product.descriptionAr && product.descriptionAr[0]) ||
|
| 2118 |
+
title
|
| 2119 |
+
).replace(
|
| 2120 |
+
/[&<>"']/g,
|
| 2121 |
+
(m) =>
|
| 2122 |
+
({
|
| 2123 |
+
'&': '&',
|
| 2124 |
+
'<': '<',
|
| 2125 |
+
'>': '>',
|
| 2126 |
+
'"': '"',
|
| 2127 |
+
"'": ''',
|
| 2128 |
+
})[m],
|
| 2129 |
+
);
|
| 2130 |
+
const link = `${frontendUrl}/product/${product.slug || id}`;
|
| 2131 |
+
const imageLink = product.imageCover || '';
|
| 2132 |
+
const price = `${product.price} EGP`;
|
| 2133 |
+
const availability = product.stock > 0 ? 'in stock' : 'out of stock';
|
| 2134 |
+
const brand = (product.brand || 'Samoulla').replace(
|
| 2135 |
+
/[&<>"']/g,
|
| 2136 |
+
(m) =>
|
| 2137 |
+
({
|
| 2138 |
+
'&': '&',
|
| 2139 |
+
'<': '<',
|
| 2140 |
+
'>': '>',
|
| 2141 |
+
'"': '"',
|
| 2142 |
+
"'": ''',
|
| 2143 |
+
})[m],
|
| 2144 |
+
);
|
| 2145 |
+
const categoryName =
|
| 2146 |
+
(product.category &&
|
| 2147 |
+
product.category[0] &&
|
| 2148 |
+
product.category[0].nameEn) ||
|
| 2149 |
+
'Tools & Equipment';
|
| 2150 |
+
const category = categoryName.replace(
|
| 2151 |
+
/[&<>"']/g,
|
| 2152 |
+
(m) =>
|
| 2153 |
+
({
|
| 2154 |
+
'&': '&',
|
| 2155 |
+
'<': '<',
|
| 2156 |
+
'>': '>',
|
| 2157 |
+
'"': '"',
|
| 2158 |
+
"'": ''',
|
| 2159 |
+
})[m],
|
| 2160 |
+
);
|
| 2161 |
+
|
| 2162 |
+
xml += `
|
| 2163 |
+
<item>
|
| 2164 |
+
<g:id>${id}</g:id>
|
| 2165 |
+
<g:title>${title}</g:title>
|
| 2166 |
+
<g:description>${description}</g:description>
|
| 2167 |
+
<g:link>${link}</g:link>
|
| 2168 |
+
<g:image_link>${imageLink}</g:image_link>
|
| 2169 |
+
<g:condition>new</g:condition>
|
| 2170 |
+
<g:availability>${availability}</g:availability>
|
| 2171 |
+
<g:price>${price}</g:price>
|
| 2172 |
+
<g:brand>${brand}</g:brand>
|
| 2173 |
+
<g:google_product_category>${category}</g:google_product_category>
|
| 2174 |
+
</item>`;
|
| 2175 |
+
});
|
| 2176 |
+
|
| 2177 |
+
xml += `
|
| 2178 |
+
</channel>
|
| 2179 |
+
</rss>`;
|
| 2180 |
+
|
| 2181 |
+
// 3. Send response with XML header
|
| 2182 |
+
res.set('Content-Type', 'text/xml');
|
| 2183 |
+
res.status(200).send(xml);
|
| 2184 |
+
} catch (error) {
|
| 2185 |
+
console.error('Facebook Catalog Error:', error);
|
| 2186 |
+
res.status(500).json({
|
| 2187 |
+
status: 'error',
|
| 2188 |
+
message: 'Failed to generate catalog',
|
| 2189 |
+
});
|
| 2190 |
+
}
|
| 2191 |
+
};
|
controllers/promoController.js
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const Promo = require('../models/promoCodeModel');
|
| 2 |
+
const Order = require('../models/orderModel');
|
| 3 |
+
|
| 4 |
+
exports.createPromo = async (req, res) => {
|
| 5 |
+
try {
|
| 6 |
+
const data = req.body;
|
| 7 |
+
if (data.code) data.code = String(data.code).trim().toUpperCase();
|
| 8 |
+
if (data.categoryId === '') data.categoryId = null;
|
| 9 |
+
const promo = await Promo.create(data);
|
| 10 |
+
res.status(201).json({
|
| 11 |
+
status: 'success',
|
| 12 |
+
data: { promo },
|
| 13 |
+
});
|
| 14 |
+
} catch (err) {
|
| 15 |
+
res.status(500).json({
|
| 16 |
+
status: 'error',
|
| 17 |
+
message: err.message,
|
| 18 |
+
});
|
| 19 |
+
}
|
| 20 |
+
};
|
| 21 |
+
|
| 22 |
+
exports.getPromos = async (req, res) => {
|
| 23 |
+
try {
|
| 24 |
+
const promos = await Promo.find().sort({ createdAt: -1 }).limit(200);
|
| 25 |
+
res.status(200).json({
|
| 26 |
+
status: 'success',
|
| 27 |
+
results: promos.length,
|
| 28 |
+
data: { promos },
|
| 29 |
+
});
|
| 30 |
+
} catch (err) {
|
| 31 |
+
res.status(500).json({
|
| 32 |
+
status: 'error',
|
| 33 |
+
message: err.message,
|
| 34 |
+
});
|
| 35 |
+
}
|
| 36 |
+
};
|
| 37 |
+
|
| 38 |
+
exports.getPromo = async (req, res) => {
|
| 39 |
+
try {
|
| 40 |
+
const promo = await Promo.findById(req.params.id);
|
| 41 |
+
if (!promo)
|
| 42 |
+
return res
|
| 43 |
+
.status(404)
|
| 44 |
+
.json({ status: 'fail', message: 'Promo not found' });
|
| 45 |
+
res.status(200).json({
|
| 46 |
+
status: 'success',
|
| 47 |
+
data: { promo },
|
| 48 |
+
});
|
| 49 |
+
} catch (err) {
|
| 50 |
+
res.status(500).json({
|
| 51 |
+
status: 'error',
|
| 52 |
+
message: err.message,
|
| 53 |
+
});
|
| 54 |
+
}
|
| 55 |
+
};
|
| 56 |
+
|
| 57 |
+
exports.updatePromo = async (req, res) => {
|
| 58 |
+
try {
|
| 59 |
+
const data = req.body;
|
| 60 |
+
if (data.code) data.code = String(data.code).trim().toUpperCase();
|
| 61 |
+
if (data.categoryId === '') data.categoryId = null;
|
| 62 |
+
const promo = await Promo.findByIdAndUpdate(req.params.id, data, {
|
| 63 |
+
new: true,
|
| 64 |
+
runValidators: true,
|
| 65 |
+
});
|
| 66 |
+
if (!promo)
|
| 67 |
+
return res
|
| 68 |
+
.status(404)
|
| 69 |
+
.json({ status: 'fail', message: 'Promo not found' });
|
| 70 |
+
res.status(200).json({
|
| 71 |
+
status: 'success',
|
| 72 |
+
data: { promo },
|
| 73 |
+
});
|
| 74 |
+
} catch (err) {
|
| 75 |
+
res.status(500).json({
|
| 76 |
+
status: 'error',
|
| 77 |
+
message: err.message,
|
| 78 |
+
});
|
| 79 |
+
}
|
| 80 |
+
};
|
| 81 |
+
|
| 82 |
+
exports.deletePromo = async (req, res) => {
|
| 83 |
+
try {
|
| 84 |
+
const promo = await Promo.findByIdAndDelete(req.params.id);
|
| 85 |
+
if (!promo)
|
| 86 |
+
return res
|
| 87 |
+
.status(404)
|
| 88 |
+
.json({ status: 'fail', message: 'Promo not found' });
|
| 89 |
+
res.status(204).json({
|
| 90 |
+
status: 'success',
|
| 91 |
+
data: null,
|
| 92 |
+
});
|
| 93 |
+
} catch (err) {
|
| 94 |
+
res.status(500).json({
|
| 95 |
+
status: 'error',
|
| 96 |
+
message: err.message,
|
| 97 |
+
});
|
| 98 |
+
}
|
| 99 |
+
};
|
| 100 |
+
const computeDiscountAmount = (type, value, base) => {
|
| 101 |
+
const numericBase = Number(base) || 0;
|
| 102 |
+
const numericValue = Number(value) || 0;
|
| 103 |
+
if (type === 'shipping') return 0; // Shipping discount doesn't affect subtotal
|
| 104 |
+
if (type === 'percentage' || type === 'welcome')
|
| 105 |
+
return Math.max(0, (numericBase * numericValue) / 100);
|
| 106 |
+
return Math.max(0, numericValue);
|
| 107 |
+
};
|
| 108 |
+
|
| 109 |
+
exports.validatePromo = async (req, res) => {
|
| 110 |
+
try {
|
| 111 |
+
const { promoCode, subtotal, items } = req.body || {};
|
| 112 |
+
const base = Number(subtotal) || 0;
|
| 113 |
+
const cartItems = Array.isArray(items) ? items : [];
|
| 114 |
+
if (!promoCode)
|
| 115 |
+
return res.status(200).json({
|
| 116 |
+
status: 'success',
|
| 117 |
+
data: { code: null, discountedTotal: base },
|
| 118 |
+
});
|
| 119 |
+
|
| 120 |
+
const upper = String(promoCode).trim().toUpperCase();
|
| 121 |
+
const now = new Date();
|
| 122 |
+
|
| 123 |
+
const promo = await Promo.findOne({ code: upper });
|
| 124 |
+
|
| 125 |
+
if (!promo) {
|
| 126 |
+
return res.status(200).json({
|
| 127 |
+
status: 'fail',
|
| 128 |
+
message: 'كود الخصم غير موجود',
|
| 129 |
+
});
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
if (!promo.active) {
|
| 133 |
+
return res.status(200).json({
|
| 134 |
+
status: 'fail',
|
| 135 |
+
message: 'كود الخصم هذا غير مفعّل حالياً',
|
| 136 |
+
});
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
if (promo.startsAt && promo.startsAt > now) {
|
| 140 |
+
return res.status(200).json({
|
| 141 |
+
status: 'fail',
|
| 142 |
+
message: 'كود الخصم لم يبدأ مفعوله بعد',
|
| 143 |
+
});
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
if (promo.expiresAt && promo.expiresAt < now) {
|
| 147 |
+
return res.status(200).json({
|
| 148 |
+
status: 'fail',
|
| 149 |
+
message: 'انتهت صلاحية كود الخصم هذا',
|
| 150 |
+
});
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
if (promo.minOrderValue > base) {
|
| 154 |
+
return res.status(200).json({
|
| 155 |
+
status: 'fail',
|
| 156 |
+
message: `يجب أن تكون قيمة الطلب ${promo.minOrderValue} جنيه على الأقل لاستخدام هذا الكود`,
|
| 157 |
+
});
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
// Check usage limits if needed
|
| 161 |
+
if (promo.usageLimit !== null && promo.usedCount >= promo.usageLimit) {
|
| 162 |
+
return res.status(200).json({
|
| 163 |
+
status: 'fail',
|
| 164 |
+
message: 'تم استهلاك الحد الأقصى لاستخدام كود الخصم هذا',
|
| 165 |
+
});
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
// Check per-user limit
|
| 169 |
+
if (req.user && promo.perUserLimit !== null) {
|
| 170 |
+
const userUsageCount = await Order.countDocuments({
|
| 171 |
+
user: req.user._id,
|
| 172 |
+
promoCode: upper,
|
| 173 |
+
orderStatus: { $ne: 'cancelled' }, // Count everything except cancelled
|
| 174 |
+
});
|
| 175 |
+
|
| 176 |
+
if (userUsageCount >= promo.perUserLimit) {
|
| 177 |
+
return res.status(200).json({
|
| 178 |
+
status: 'fail',
|
| 179 |
+
message: `لقد استنفدت الحد الأقصى لاستخدام هذا الكود (${promo.perUserLimit} مرات)`,
|
| 180 |
+
});
|
| 181 |
+
}
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
// Determine eligible base for discount calculation
|
| 185 |
+
let eligibleBase = base;
|
| 186 |
+
if (promo.canBeUsedWithSaleItems === false && cartItems.length > 0) {
|
| 187 |
+
eligibleBase = cartItems.reduce((sum, item) => {
|
| 188 |
+
// If product is NOT on sale, it is eligible
|
| 189 |
+
if (!item.isOnSale) {
|
| 190 |
+
return sum + Number(item.price) * Number(item.quantity);
|
| 191 |
+
}
|
| 192 |
+
return sum;
|
| 193 |
+
}, 0);
|
| 194 |
+
|
| 195 |
+
// If no eligible items and it's not a shipping discount, return error
|
| 196 |
+
if (eligibleBase <= 0 && promo.type !== 'shipping') {
|
| 197 |
+
return res.status(200).json({
|
| 198 |
+
status: 'fail',
|
| 199 |
+
message: 'كود الخصم هذا لا يمكن استخدامه مع المنتجات المخفضة',
|
| 200 |
+
});
|
| 201 |
+
}
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
const discount = computeDiscountAmount(
|
| 205 |
+
promo.type,
|
| 206 |
+
promo.value,
|
| 207 |
+
eligibleBase,
|
| 208 |
+
);
|
| 209 |
+
const discounted = Math.max(0, base - discount);
|
| 210 |
+
|
| 211 |
+
return res.status(200).json({
|
| 212 |
+
status: 'success',
|
| 213 |
+
data: {
|
| 214 |
+
code: promo.code,
|
| 215 |
+
discountType: promo.type,
|
| 216 |
+
discountValue: promo.value,
|
| 217 |
+
discountedTotal: discounted,
|
| 218 |
+
isFreeShipping: promo.type === 'shipping',
|
| 219 |
+
expiresAt: promo.expiresAt,
|
| 220 |
+
},
|
| 221 |
+
});
|
| 222 |
+
} catch (err) {
|
| 223 |
+
res.status(500).json({ status: 'error', message: err.message });
|
| 224 |
+
}
|
| 225 |
+
};
|
controllers/providerController.js
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const Provider = require('../models/providerModel');
|
| 2 |
+
const User = require('../models/userModel');
|
| 3 |
+
const Product = require('../models/productModel');
|
| 4 |
+
const Order = require('../models/orderModel');
|
| 5 |
+
const { deleteImage, getPublicIdFromUrl } = require('../config/cloudinary');
|
| 6 |
+
|
| 7 |
+
// Get all providers
|
| 8 |
+
exports.getAllProviders = async (req, res) => {
|
| 9 |
+
try {
|
| 10 |
+
const providers = await Provider.find().populate('user', 'email');
|
| 11 |
+
|
| 12 |
+
// Calculate stats for each provider
|
| 13 |
+
const providersWithStats = await Promise.all(
|
| 14 |
+
providers.map(async (provider) => {
|
| 15 |
+
const providerData = provider.toObject();
|
| 16 |
+
|
| 17 |
+
// Add email from user
|
| 18 |
+
if (provider.user) {
|
| 19 |
+
providerData.email = provider.user.email;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
// Count total products for this provider
|
| 23 |
+
const totalProducts = await Product.countDocuments({
|
| 24 |
+
provider: provider._id,
|
| 25 |
+
});
|
| 26 |
+
|
| 27 |
+
// Get all products for this provider
|
| 28 |
+
const products = await Product.find({ provider: provider._id }).select(
|
| 29 |
+
'_id',
|
| 30 |
+
);
|
| 31 |
+
const productIds = products.map((p) => p._id);
|
| 32 |
+
|
| 33 |
+
// Calculate total sales and order count from COMPLETED orders only
|
| 34 |
+
const orders = await Order.find({
|
| 35 |
+
'items.product': { $in: productIds },
|
| 36 |
+
orderStatus: 'completed', // Only count delivered orders
|
| 37 |
+
});
|
| 38 |
+
|
| 39 |
+
let totalSales = 0;
|
| 40 |
+
const orderSet = new Set();
|
| 41 |
+
|
| 42 |
+
orders.forEach((order) => {
|
| 43 |
+
orderSet.add(order._id.toString());
|
| 44 |
+
// Sum up sales for this provider's products in each order
|
| 45 |
+
order.items.forEach((item) => {
|
| 46 |
+
if (productIds.some((id) => id.equals(item.product))) {
|
| 47 |
+
totalSales += item.quantity * item.unitPrice;
|
| 48 |
+
}
|
| 49 |
+
});
|
| 50 |
+
});
|
| 51 |
+
|
| 52 |
+
providerData.totalProducts = totalProducts;
|
| 53 |
+
providerData.totalSales = totalSales;
|
| 54 |
+
providerData.orderCount = orderSet.size;
|
| 55 |
+
|
| 56 |
+
return providerData;
|
| 57 |
+
}),
|
| 58 |
+
);
|
| 59 |
+
|
| 60 |
+
res.status(200).json({
|
| 61 |
+
status: 'success',
|
| 62 |
+
results: providersWithStats.length,
|
| 63 |
+
data: {
|
| 64 |
+
providers: providersWithStats,
|
| 65 |
+
},
|
| 66 |
+
});
|
| 67 |
+
} catch (err) {
|
| 68 |
+
res.status(500).json({
|
| 69 |
+
status: 'fail',
|
| 70 |
+
message: err.message,
|
| 71 |
+
});
|
| 72 |
+
}
|
| 73 |
+
};
|
| 74 |
+
|
| 75 |
+
// Get single provider
|
| 76 |
+
exports.getProvider = async (req, res) => {
|
| 77 |
+
try {
|
| 78 |
+
const provider = await Provider.findById(req.params.id).populate(
|
| 79 |
+
'user',
|
| 80 |
+
'email',
|
| 81 |
+
);
|
| 82 |
+
if (!provider) {
|
| 83 |
+
return res.status(404).json({
|
| 84 |
+
status: 'fail',
|
| 85 |
+
message: 'No provider found with that ID',
|
| 86 |
+
});
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
// Include email from user in response
|
| 90 |
+
const providerData = provider.toObject();
|
| 91 |
+
if (provider.user) {
|
| 92 |
+
providerData.email = provider.user.email;
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
res.status(200).json({
|
| 96 |
+
status: 'success',
|
| 97 |
+
data: { provider: providerData },
|
| 98 |
+
});
|
| 99 |
+
} catch (err) {
|
| 100 |
+
res.status(400).json({
|
| 101 |
+
status: 'fail',
|
| 102 |
+
message: err.message,
|
| 103 |
+
});
|
| 104 |
+
}
|
| 105 |
+
};
|
| 106 |
+
|
| 107 |
+
// Create a new provider
|
| 108 |
+
exports.createProvider = async (req, res) => {
|
| 109 |
+
try {
|
| 110 |
+
const { email, password, name, storeName, phoneNumber, address } = req.body;
|
| 111 |
+
|
| 112 |
+
// 1. Check if email or phone already exists in User collection
|
| 113 |
+
const existingUser = await User.findOne({
|
| 114 |
+
$or: [{ email }, { phone: phoneNumber }],
|
| 115 |
+
});
|
| 116 |
+
if (existingUser) {
|
| 117 |
+
const field =
|
| 118 |
+
existingUser.email === email ? 'البريد الإلكتروني' : 'رقم الهاتف';
|
| 119 |
+
return res.status(400).json({
|
| 120 |
+
status: 'fail',
|
| 121 |
+
message: `${field} مسجل بالفعل لمستخدم آخر`,
|
| 122 |
+
});
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
// 2. Get next provider ID
|
| 126 |
+
const lastProvider = await Provider.findOne().sort({ id: -1 });
|
| 127 |
+
const nextId = lastProvider ? lastProvider.id + 1 : 1;
|
| 128 |
+
|
| 129 |
+
const providerData = {
|
| 130 |
+
name,
|
| 131 |
+
storeName,
|
| 132 |
+
phoneNumber,
|
| 133 |
+
address,
|
| 134 |
+
id: nextId,
|
| 135 |
+
};
|
| 136 |
+
|
| 137 |
+
if (req.file) {
|
| 138 |
+
providerData.logo = req.file.path;
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
// 3. Create Provider document first
|
| 142 |
+
let newProvider;
|
| 143 |
+
try {
|
| 144 |
+
newProvider = await Provider.create(providerData);
|
| 145 |
+
} catch (err) {
|
| 146 |
+
if (err.code === 11000) {
|
| 147 |
+
const field = Object.keys(err.keyPattern)[0];
|
| 148 |
+
const message =
|
| 149 |
+
field === 'storeName'
|
| 150 |
+
? 'اسم المتجر مسجل بالفعل'
|
| 151 |
+
: 'رقم الهاتف مسجل بالفعل في قائمة الموردين';
|
| 152 |
+
return res.status(400).json({ status: 'fail', message });
|
| 153 |
+
}
|
| 154 |
+
throw err;
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
// 4. Create User account for vendor
|
| 158 |
+
try {
|
| 159 |
+
const vendorUser = await User.create({
|
| 160 |
+
name,
|
| 161 |
+
email,
|
| 162 |
+
phone: phoneNumber,
|
| 163 |
+
password,
|
| 164 |
+
role: 'vendor',
|
| 165 |
+
provider: newProvider._id,
|
| 166 |
+
});
|
| 167 |
+
|
| 168 |
+
// 5. Update provider with user reference
|
| 169 |
+
newProvider.user = vendorUser._id;
|
| 170 |
+
await newProvider.save();
|
| 171 |
+
|
| 172 |
+
res.status(201).json({
|
| 173 |
+
status: 'success',
|
| 174 |
+
data: { provider: newProvider },
|
| 175 |
+
});
|
| 176 |
+
} catch (err) {
|
| 177 |
+
// Rollback: Delete the created provider if user creation fails
|
| 178 |
+
await Provider.findByIdAndDelete(newProvider._id);
|
| 179 |
+
throw err;
|
| 180 |
+
}
|
| 181 |
+
} catch (err) {
|
| 182 |
+
res.status(400).json({
|
| 183 |
+
status: 'fail',
|
| 184 |
+
message: err.message,
|
| 185 |
+
});
|
| 186 |
+
}
|
| 187 |
+
};
|
| 188 |
+
|
| 189 |
+
// Update a provider
|
| 190 |
+
exports.updateProvider = async (req, res) => {
|
| 191 |
+
try {
|
| 192 |
+
const providerId = req.params.id;
|
| 193 |
+
const oldProvider = await Provider.findById(providerId);
|
| 194 |
+
|
| 195 |
+
if (!oldProvider) {
|
| 196 |
+
return res.status(404).json({
|
| 197 |
+
status: 'fail',
|
| 198 |
+
message: 'No provider found with that ID',
|
| 199 |
+
});
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
const updateData = { ...req.body };
|
| 203 |
+
|
| 204 |
+
// Handle logo update or deletion
|
| 205 |
+
if (req.file) {
|
| 206 |
+
// New logo uploaded
|
| 207 |
+
updateData.logo = req.file.path;
|
| 208 |
+
|
| 209 |
+
// Delete old logo from Cloudinary if it exists
|
| 210 |
+
if (oldProvider.logo) {
|
| 211 |
+
const publicId = getPublicIdFromUrl(oldProvider.logo);
|
| 212 |
+
if (publicId) {
|
| 213 |
+
await deleteImage(publicId).catch((err) =>
|
| 214 |
+
console.error('Cloudinary delete error:', err),
|
| 215 |
+
);
|
| 216 |
+
}
|
| 217 |
+
}
|
| 218 |
+
} else if (req.body.logo === '') {
|
| 219 |
+
// Logo explicitly removed
|
| 220 |
+
updateData.logo = '';
|
| 221 |
+
|
| 222 |
+
// Delete old logo from Cloudinary if it exists
|
| 223 |
+
if (oldProvider.logo) {
|
| 224 |
+
const publicId = getPublicIdFromUrl(oldProvider.logo);
|
| 225 |
+
if (publicId) {
|
| 226 |
+
await deleteImage(publicId).catch((err) =>
|
| 227 |
+
console.error('Cloudinary delete error:', err),
|
| 228 |
+
);
|
| 229 |
+
}
|
| 230 |
+
}
|
| 231 |
+
} else {
|
| 232 |
+
// Keep existing logo - remove it from updateData so it doesn't try to update with a string/URL
|
| 233 |
+
delete updateData.logo;
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
const updatedProvider = await Provider.findByIdAndUpdate(
|
| 237 |
+
providerId,
|
| 238 |
+
updateData,
|
| 239 |
+
{ new: true, runValidators: true },
|
| 240 |
+
);
|
| 241 |
+
|
| 242 |
+
if (!updatedProvider) {
|
| 243 |
+
return res.status(404).json({
|
| 244 |
+
status: 'fail',
|
| 245 |
+
message: 'No provider found with that ID',
|
| 246 |
+
});
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
// Also update the associated User's email, phone, and password if they changed
|
| 250 |
+
if (
|
| 251 |
+
updatedProvider.user &&
|
| 252 |
+
(req.body.email || req.body.phoneNumber || req.body.password)
|
| 253 |
+
) {
|
| 254 |
+
const user = await User.findById(updatedProvider.user);
|
| 255 |
+
if (user) {
|
| 256 |
+
if (req.body.email) user.email = req.body.email;
|
| 257 |
+
if (req.body.phoneNumber) user.phone = req.body.phoneNumber;
|
| 258 |
+
if (req.body.password) user.password = req.body.password;
|
| 259 |
+
|
| 260 |
+
await user.save();
|
| 261 |
+
}
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
res.status(200).json({
|
| 265 |
+
status: 'success',
|
| 266 |
+
data: { provider: updatedProvider },
|
| 267 |
+
});
|
| 268 |
+
} catch (err) {
|
| 269 |
+
if (err.code === 11000) {
|
| 270 |
+
const field = Object.keys(err.keyPattern)[0];
|
| 271 |
+
const message =
|
| 272 |
+
field === 'storeName'
|
| 273 |
+
? 'اسم المتجر مسجل بالفعل'
|
| 274 |
+
: 'رقم الهاتف أو البريد الإلكتروني مسجل بالفعل';
|
| 275 |
+
return res.status(400).json({ status: 'fail', message });
|
| 276 |
+
}
|
| 277 |
+
res.status(400).json({
|
| 278 |
+
status: 'fail',
|
| 279 |
+
message: err.message,
|
| 280 |
+
});
|
| 281 |
+
}
|
| 282 |
+
};
|
| 283 |
+
|
| 284 |
+
// Delete a provider
|
| 285 |
+
exports.deleteProvider = async (req, res) => {
|
| 286 |
+
try {
|
| 287 |
+
const provider = await Provider.findById(req.params.id);
|
| 288 |
+
if (!provider) {
|
| 289 |
+
return res.status(404).json({
|
| 290 |
+
status: 'fail',
|
| 291 |
+
message: 'No provider found with that ID',
|
| 292 |
+
});
|
| 293 |
+
}
|
| 294 |
+
|
| 295 |
+
// Delete associated user
|
| 296 |
+
if (provider.user) {
|
| 297 |
+
await User.findByIdAndDelete(provider.user);
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
// Delete logo from Cloudinary if it exists
|
| 301 |
+
if (provider.logo) {
|
| 302 |
+
const publicId = getPublicIdFromUrl(provider.logo);
|
| 303 |
+
if (publicId) await deleteImage(publicId);
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
await Provider.findByIdAndDelete(req.params.id);
|
| 307 |
+
res.status(204).json({
|
| 308 |
+
status: 'success',
|
| 309 |
+
data: null,
|
| 310 |
+
});
|
| 311 |
+
} catch (err) {
|
| 312 |
+
res.status(400).json({
|
| 313 |
+
status: 'fail',
|
| 314 |
+
message: err.message,
|
| 315 |
+
});
|
| 316 |
+
}
|
| 317 |
+
};
|
controllers/returnRequestController.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const ReturnRequest = require('../models/returnRequestModel');
|
| 2 |
+
|
| 3 |
+
// 1. Submit Return Request (Public)
|
| 4 |
+
exports.submitRequest = async (req, res) => {
|
| 5 |
+
try {
|
| 6 |
+
const newRequest = await ReturnRequest.create(req.body);
|
| 7 |
+
|
| 8 |
+
res.status(201).json({
|
| 9 |
+
status: 'success',
|
| 10 |
+
message: 'تم إرسال طلب الاسترجاع بنجاح',
|
| 11 |
+
data: { request: newRequest },
|
| 12 |
+
});
|
| 13 |
+
} catch (err) {
|
| 14 |
+
res.status(400).json({
|
| 15 |
+
status: 'fail',
|
| 16 |
+
message: err.message,
|
| 17 |
+
});
|
| 18 |
+
}
|
| 19 |
+
};
|
| 20 |
+
|
| 21 |
+
// 2. Get All Requests (Admin)
|
| 22 |
+
exports.getAllRequests = async (req, res) => {
|
| 23 |
+
try {
|
| 24 |
+
const requests = await ReturnRequest.find().sort({ createdAt: -1 });
|
| 25 |
+
res.status(200).json({
|
| 26 |
+
status: 'success',
|
| 27 |
+
results: requests.length,
|
| 28 |
+
data: { requests },
|
| 29 |
+
});
|
| 30 |
+
} catch (err) {
|
| 31 |
+
res.status(500).json({
|
| 32 |
+
status: 'fail',
|
| 33 |
+
message: err.message,
|
| 34 |
+
});
|
| 35 |
+
}
|
| 36 |
+
};
|
| 37 |
+
|
| 38 |
+
// 3. Delete Request (Admin)
|
| 39 |
+
exports.deleteRequest = async (req, res) => {
|
| 40 |
+
try {
|
| 41 |
+
const request = await ReturnRequest.findByIdAndDelete(req.params.id);
|
| 42 |
+
if (!request) {
|
| 43 |
+
return res.status(404).json({
|
| 44 |
+
status: 'fail',
|
| 45 |
+
message: 'No request found with that ID',
|
| 46 |
+
});
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
res.status(204).json({
|
| 50 |
+
status: 'success',
|
| 51 |
+
data: null,
|
| 52 |
+
});
|
| 53 |
+
} catch (err) {
|
| 54 |
+
res.status(400).json({
|
| 55 |
+
status: 'fail',
|
| 56 |
+
message: err.message,
|
| 57 |
+
});
|
| 58 |
+
}
|
| 59 |
+
};
|
| 60 |
+
|
| 61 |
+
// 4. Toggle Read Status (Admin)
|
| 62 |
+
exports.toggleReadStatus = async (req, res) => {
|
| 63 |
+
try {
|
| 64 |
+
const request = await ReturnRequest.findById(req.params.id);
|
| 65 |
+
if (!request) {
|
| 66 |
+
return res.status(404).json({
|
| 67 |
+
status: 'fail',
|
| 68 |
+
message: 'No request found with that ID',
|
| 69 |
+
});
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
request.isRead = !request.isRead;
|
| 73 |
+
await request.save();
|
| 74 |
+
|
| 75 |
+
res.status(200).json({
|
| 76 |
+
status: 'success',
|
| 77 |
+
data: { request },
|
| 78 |
+
});
|
| 79 |
+
} catch (err) {
|
| 80 |
+
res.status(400).json({
|
| 81 |
+
status: 'fail',
|
| 82 |
+
message: err.message,
|
| 83 |
+
});
|
| 84 |
+
}
|
| 85 |
+
};
|
controllers/reviewController.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const Review = require('../models/reviewModel');
|
| 2 |
+
const Product = require('../models/productModel');
|
| 3 |
+
|
| 4 |
+
// create a review
|
| 5 |
+
exports.createReview = async (req, res) => {
|
| 6 |
+
try {
|
| 7 |
+
const { product, rating, review } = req.body;
|
| 8 |
+
|
| 9 |
+
// Ensure product exists
|
| 10 |
+
const existingProduct = await Product.findById(product);
|
| 11 |
+
if (!existingProduct) {
|
| 12 |
+
return res.status(400).json({ message: 'Invalid product ID' });
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
// Prevent duplicate reviews by same user
|
| 16 |
+
const existingReview = await Review.findOne({
|
| 17 |
+
product,
|
| 18 |
+
user: req.user._id,
|
| 19 |
+
});
|
| 20 |
+
if (existingReview) {
|
| 21 |
+
return res
|
| 22 |
+
.status(400)
|
| 23 |
+
.json({ message: 'You already reviewed this product' });
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
// Create review
|
| 27 |
+
const newReview = await Review.create({
|
| 28 |
+
product,
|
| 29 |
+
user: req.user._id,
|
| 30 |
+
rating,
|
| 31 |
+
review,
|
| 32 |
+
});
|
| 33 |
+
|
| 34 |
+
res.status(201).json({
|
| 35 |
+
status: 'success',
|
| 36 |
+
data: newReview,
|
| 37 |
+
});
|
| 38 |
+
} catch (error) {
|
| 39 |
+
res.status(500).json({ message: error.message });
|
| 40 |
+
}
|
| 41 |
+
};
|
| 42 |
+
|
| 43 |
+
// get all reviews
|
| 44 |
+
exports.getAllReviews = async (req, res) => {
|
| 45 |
+
try {
|
| 46 |
+
const reviews = await Review.find().populate({
|
| 47 |
+
path: 'product',
|
| 48 |
+
select: 'nameEn nameAr price imageCover',
|
| 49 |
+
});
|
| 50 |
+
|
| 51 |
+
res.status(200).json({
|
| 52 |
+
status: 'success',
|
| 53 |
+
results: reviews.length,
|
| 54 |
+
data: reviews,
|
| 55 |
+
});
|
| 56 |
+
} catch (error) {
|
| 57 |
+
res.status(500).json({ message: error.message });
|
| 58 |
+
}
|
| 59 |
+
};
|
| 60 |
+
|
| 61 |
+
// get all reviews for a product
|
| 62 |
+
exports.getProductReviews = async (req, res) => {
|
| 63 |
+
try {
|
| 64 |
+
const reviews = await Review.find({ product: req.params.productId });
|
| 65 |
+
|
| 66 |
+
res.status(200).json({
|
| 67 |
+
status: 'success',
|
| 68 |
+
results: reviews.length,
|
| 69 |
+
data: reviews,
|
| 70 |
+
});
|
| 71 |
+
} catch (error) {
|
| 72 |
+
res.status(500).json({ message: error.message });
|
| 73 |
+
}
|
| 74 |
+
};
|
| 75 |
+
|
| 76 |
+
// Update a review
|
| 77 |
+
exports.updateReview = async (req, res) => {
|
| 78 |
+
try {
|
| 79 |
+
const review = await Review.findById(req.params.id);
|
| 80 |
+
|
| 81 |
+
if (!review) {
|
| 82 |
+
return res.status(404).json({ message: 'Review not found' });
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
// Only review owner can edit
|
| 86 |
+
if (review.user.toString() !== req.user._id.toString()) {
|
| 87 |
+
return res.status(403).json({ message: 'Not authorized' });
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
const updated = await Review.findByIdAndUpdate(
|
| 91 |
+
req.params.id,
|
| 92 |
+
{ rating: req.body.rating, review: req.body.review },
|
| 93 |
+
{ new: true, runValidators: true },
|
| 94 |
+
);
|
| 95 |
+
|
| 96 |
+
// Trigger recalculation manually (because findByIdAndUpdate doesn’t trigger post('save'))
|
| 97 |
+
await Review.calcAverageRatings(review.product);
|
| 98 |
+
|
| 99 |
+
res.status(200).json({
|
| 100 |
+
status: 'success',
|
| 101 |
+
data: updated,
|
| 102 |
+
});
|
| 103 |
+
} catch (error) {
|
| 104 |
+
res.status(500).json({ message: error.message });
|
| 105 |
+
}
|
| 106 |
+
};
|
| 107 |
+
|
| 108 |
+
// Delete a review
|
| 109 |
+
exports.deleteReview = async (req, res) => {
|
| 110 |
+
try {
|
| 111 |
+
const review = await Review.findById(req.params.id);
|
| 112 |
+
|
| 113 |
+
if (!review) {
|
| 114 |
+
return res.status(404).json({ message: 'Review not found' });
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
// Only review owner or admin can delete
|
| 118 |
+
if (review.user.toString() !== req.user._id.toString()) {
|
| 119 |
+
return res.status(403).json({ message: 'Not authorized' });
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
await Review.findByIdAndDelete(req.params.id);
|
| 123 |
+
await Review.calcAverageRatings(review.product);
|
| 124 |
+
|
| 125 |
+
res.status(204).json({ status: 'success', data: null });
|
| 126 |
+
} catch (error) {
|
| 127 |
+
res.status(500).json({ message: error.message });
|
| 128 |
+
}
|
| 129 |
+
};
|
controllers/settingsController.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const SiteSettings = require('../models/siteSettingsModel');
|
| 2 |
+
|
| 3 |
+
exports.getSettings = async (req, res) => {
|
| 4 |
+
try {
|
| 5 |
+
const settings = await SiteSettings.getSettings();
|
| 6 |
+
res.status(200).json({
|
| 7 |
+
status: 'success',
|
| 8 |
+
data: { settings },
|
| 9 |
+
});
|
| 10 |
+
} catch (err) {
|
| 11 |
+
res.status(500).json({
|
| 12 |
+
status: 'error',
|
| 13 |
+
message: err.message,
|
| 14 |
+
});
|
| 15 |
+
}
|
| 16 |
+
};
|
| 17 |
+
|
| 18 |
+
exports.updateSettings = async (req, res) => {
|
| 19 |
+
try {
|
| 20 |
+
// getSettings handles finding or creating
|
| 21 |
+
let settings = await SiteSettings.getSettings();
|
| 22 |
+
|
| 23 |
+
// Update fields
|
| 24 |
+
if (req.body.checkout) {
|
| 25 |
+
settings.checkout = { ...settings.checkout.toObject(), ...req.body.checkout };
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
// Add other settings updates here as needed (topBar, footer, etc.)
|
| 29 |
+
if (req.body.topBar) settings.topBar = { ...settings.topBar.toObject(), ...req.body.topBar };
|
| 30 |
+
if (req.body.footer) settings.footer = { ...settings.footer.toObject(), ...req.body.footer };
|
| 31 |
+
|
| 32 |
+
await settings.save();
|
| 33 |
+
|
| 34 |
+
res.status(200).json({
|
| 35 |
+
status: 'success',
|
| 36 |
+
data: { settings },
|
| 37 |
+
});
|
| 38 |
+
} catch (err) {
|
| 39 |
+
res.status(500).json({
|
| 40 |
+
status: 'error',
|
| 41 |
+
message: err.message,
|
| 42 |
+
});
|
| 43 |
+
}
|
| 44 |
+
};
|
controllers/shippingController.js
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const bostaService = require('../utils/bostaService');
|
| 2 |
+
const ShippingPrice = require('../models/shippingPriceModel');
|
| 3 |
+
|
| 4 |
+
// Get all Cities (Governorates) merged with our local price data
|
| 5 |
+
exports.getGovernorates = async (req, res) => {
|
| 6 |
+
try {
|
| 7 |
+
const bostaData = await bostaService.getBostaCities();
|
| 8 |
+
const governorates = bostaData.data || bostaData;
|
| 9 |
+
|
| 10 |
+
// Fetch our local pricing data
|
| 11 |
+
const localPrices = await ShippingPrice.find();
|
| 12 |
+
const priceMap = new Map(localPrices.map((p) => [p.bostaCityId, p]));
|
| 13 |
+
|
| 14 |
+
// Merge price data into governorates list
|
| 15 |
+
const mergedGovernorates = governorates.map((gov) => {
|
| 16 |
+
const localData = priceMap.get(gov._id);
|
| 17 |
+
return {
|
| 18 |
+
...gov,
|
| 19 |
+
// Normalize names for frontend (Bosta uses name and nameAr strings)
|
| 20 |
+
name: {
|
| 21 |
+
ar:
|
| 22 |
+
gov.nameAr ||
|
| 23 |
+
gov.name ||
|
| 24 |
+
(gov._id === 'Jrb6X6ucjiYgMP4T7' ? 'الإسكندرية' : ''),
|
| 25 |
+
en: gov.name || (gov._id === 'Jrb6X6ucjiYgMP4T7' ? 'Alexandria' : ''),
|
| 26 |
+
},
|
| 27 |
+
fees: localData ? localData.fees || 0 : 0,
|
| 28 |
+
deliveryDays: localData ? localData.deliveryDays || 3 : 3,
|
| 29 |
+
isActive: localData ? localData.isActive !== false : true,
|
| 30 |
+
};
|
| 31 |
+
});
|
| 32 |
+
|
| 33 |
+
res.status(200).json({
|
| 34 |
+
status: 'success',
|
| 35 |
+
results: mergedGovernorates.length,
|
| 36 |
+
data: {
|
| 37 |
+
governorates: mergedGovernorates,
|
| 38 |
+
},
|
| 39 |
+
});
|
| 40 |
+
} catch (err) {
|
| 41 |
+
res.status(500).json({
|
| 42 |
+
status: 'error',
|
| 43 |
+
message: 'Failed to fetch governorates',
|
| 44 |
+
error: err.message,
|
| 45 |
+
});
|
| 46 |
+
}
|
| 47 |
+
};
|
| 48 |
+
|
| 49 |
+
// Update or Create Shipping Fee for a City
|
| 50 |
+
exports.updateShippingFee = async (req, res) => {
|
| 51 |
+
try {
|
| 52 |
+
const {
|
| 53 |
+
bostaCityId,
|
| 54 |
+
bostaZoneId,
|
| 55 |
+
type = 'city',
|
| 56 |
+
fees,
|
| 57 |
+
deliveryDays,
|
| 58 |
+
areaNameAr,
|
| 59 |
+
areaNameEn,
|
| 60 |
+
isActive,
|
| 61 |
+
} = req.body;
|
| 62 |
+
|
| 63 |
+
if (!bostaCityId) {
|
| 64 |
+
return res.status(400).json({
|
| 65 |
+
status: 'fail',
|
| 66 |
+
message: 'Bosta City ID is required',
|
| 67 |
+
});
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
// Search by both City and Zone ID to ensure uniqueness for zones
|
| 71 |
+
// For better control, we'll explicitly pick which one to match if multiple exist
|
| 72 |
+
// But usually there should be only one city document
|
| 73 |
+
|
| 74 |
+
const shippingPrice = await ShippingPrice.findOneAndUpdate(
|
| 75 |
+
{ bostaCityId, bostaZoneId: bostaZoneId || null },
|
| 76 |
+
{
|
| 77 |
+
fees,
|
| 78 |
+
deliveryDays,
|
| 79 |
+
areaNameAr,
|
| 80 |
+
areaNameEn,
|
| 81 |
+
type,
|
| 82 |
+
isActive,
|
| 83 |
+
},
|
| 84 |
+
{
|
| 85 |
+
new: true,
|
| 86 |
+
upsert: true,
|
| 87 |
+
runValidators: true,
|
| 88 |
+
setDefaultsOnInsert: true,
|
| 89 |
+
},
|
| 90 |
+
);
|
| 91 |
+
|
| 92 |
+
res.status(200).json({
|
| 93 |
+
status: 'success',
|
| 94 |
+
data: {
|
| 95 |
+
shippingPrice,
|
| 96 |
+
},
|
| 97 |
+
});
|
| 98 |
+
} catch (err) {
|
| 99 |
+
res.status(500).json({
|
| 100 |
+
status: 'error',
|
| 101 |
+
message: 'Failed to update shipping fee',
|
| 102 |
+
error: err.message,
|
| 103 |
+
});
|
| 104 |
+
}
|
| 105 |
+
};
|
| 106 |
+
|
| 107 |
+
// Get Zones (Areas/Districts) for a City
|
| 108 |
+
exports.getCities = async (req, res) => {
|
| 109 |
+
try {
|
| 110 |
+
const { id } = req.params; // governorate ID
|
| 111 |
+
const data = await bostaService.getBostaZones(id);
|
| 112 |
+
const zones = data.data || data;
|
| 113 |
+
|
| 114 |
+
// Fetch local prices for these zones
|
| 115 |
+
const localPrices = await ShippingPrice.find({
|
| 116 |
+
bostaCityId: id,
|
| 117 |
+
type: 'zone',
|
| 118 |
+
});
|
| 119 |
+
const priceMap = new Map(localPrices.map((p) => [p.bostaZoneId, p]));
|
| 120 |
+
|
| 121 |
+
const mergedZones = zones.map((zone) => {
|
| 122 |
+
const localData = priceMap.get(zone._id);
|
| 123 |
+
return {
|
| 124 |
+
...zone,
|
| 125 |
+
name: {
|
| 126 |
+
ar: zone.nameAr || zone.name,
|
| 127 |
+
en: zone.name,
|
| 128 |
+
},
|
| 129 |
+
fees: localData ? localData.fees : 0,
|
| 130 |
+
isActive: localData ? localData.isActive : true,
|
| 131 |
+
};
|
| 132 |
+
});
|
| 133 |
+
|
| 134 |
+
res.status(200).json({
|
| 135 |
+
status: 'success',
|
| 136 |
+
results: mergedZones.length,
|
| 137 |
+
data: {
|
| 138 |
+
cities: mergedZones,
|
| 139 |
+
},
|
| 140 |
+
});
|
| 141 |
+
} catch (err) {
|
| 142 |
+
res.status(500).json({
|
| 143 |
+
status: 'error',
|
| 144 |
+
message: 'Failed to fetch cities',
|
| 145 |
+
error: err.message,
|
| 146 |
+
});
|
| 147 |
+
}
|
| 148 |
+
};
|
| 149 |
+
// Get Districts for a City/Governorate
|
| 150 |
+
exports.getDistricts = async (req, res) => {
|
| 151 |
+
try {
|
| 152 |
+
const { id } = req.params; // governorate ID
|
| 153 |
+
const data = await bostaService.getBostaDistricts(id);
|
| 154 |
+
const districts = data.data || data;
|
| 155 |
+
|
| 156 |
+
const normalizedDistricts = districts.map((d) => ({
|
| 157 |
+
_id: d.districtId,
|
| 158 |
+
zoneId: d.zoneId,
|
| 159 |
+
name: {
|
| 160 |
+
ar: d.districtOtherName || d.districtName,
|
| 161 |
+
en: d.districtName,
|
| 162 |
+
},
|
| 163 |
+
}));
|
| 164 |
+
|
| 165 |
+
res.status(200).json({
|
| 166 |
+
status: 'success',
|
| 167 |
+
results: normalizedDistricts.length,
|
| 168 |
+
data: {
|
| 169 |
+
districts: normalizedDistricts,
|
| 170 |
+
},
|
| 171 |
+
});
|
| 172 |
+
} catch (err) {
|
| 173 |
+
res.status(500).json({
|
| 174 |
+
status: 'error',
|
| 175 |
+
message: 'Failed to fetch districts',
|
| 176 |
+
error: err.message,
|
| 177 |
+
});
|
| 178 |
+
}
|
| 179 |
+
};
|
controllers/userManagementController.js
ADDED
|
@@ -0,0 +1,473 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const User = require('../models/userModel');
|
| 2 |
+
const Provider = require('../models/providerModel');
|
| 3 |
+
const Newsletter = require('../models/newsletterModel');
|
| 4 |
+
const bcrypt = require('bcryptjs');
|
| 5 |
+
|
| 6 |
+
// Get all users (with filtering by role)
|
| 7 |
+
exports.getAllUsers = async (req, res) => {
|
| 8 |
+
try {
|
| 9 |
+
const { role, isActive } = req.query;
|
| 10 |
+
let users;
|
| 11 |
+
|
| 12 |
+
if (role === 'subscriber') {
|
| 13 |
+
// Find all active newsletter emails
|
| 14 |
+
const subscribers = await Newsletter.find({ isActive: true }).select(
|
| 15 |
+
'email',
|
| 16 |
+
);
|
| 17 |
+
const emails = subscribers.map((s) => s.email);
|
| 18 |
+
|
| 19 |
+
// Find users whose email is in the subscribers list (for in-app notifications)
|
| 20 |
+
users = await User.find({ email: { $in: emails } }).select('-password');
|
| 21 |
+
|
| 22 |
+
// For the results count, we show the total number of newsletter subscribers
|
| 23 |
+
return res.status(200).json({
|
| 24 |
+
status: 'success',
|
| 25 |
+
results: subscribers.length,
|
| 26 |
+
data: {
|
| 27 |
+
users,
|
| 28 |
+
totalSubscribers: subscribers.length,
|
| 29 |
+
},
|
| 30 |
+
});
|
| 31 |
+
} else {
|
| 32 |
+
const filter = {};
|
| 33 |
+
if (role) filter.role = role;
|
| 34 |
+
if (isActive !== undefined) filter.isActive = isActive === 'true';
|
| 35 |
+
|
| 36 |
+
users = await User.find(filter).select('-password');
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
res.status(200).json({
|
| 40 |
+
status: 'success',
|
| 41 |
+
results: users.length,
|
| 42 |
+
data: {
|
| 43 |
+
users,
|
| 44 |
+
},
|
| 45 |
+
});
|
| 46 |
+
} catch (err) {
|
| 47 |
+
res.status(500).json({
|
| 48 |
+
status: 'error',
|
| 49 |
+
message: 'Failed to fetch users',
|
| 50 |
+
error: err.message,
|
| 51 |
+
});
|
| 52 |
+
}
|
| 53 |
+
};
|
| 54 |
+
|
| 55 |
+
// Get single user by ID
|
| 56 |
+
exports.getUser = async (req, res) => {
|
| 57 |
+
try {
|
| 58 |
+
const user = await User.findById(req.params.id).select('-password');
|
| 59 |
+
|
| 60 |
+
if (!user) {
|
| 61 |
+
return res.status(404).json({
|
| 62 |
+
status: 'fail',
|
| 63 |
+
message: 'User not found',
|
| 64 |
+
});
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
res.status(200).json({
|
| 68 |
+
status: 'success',
|
| 69 |
+
data: {
|
| 70 |
+
user,
|
| 71 |
+
},
|
| 72 |
+
});
|
| 73 |
+
} catch (err) {
|
| 74 |
+
res.status(500).json({
|
| 75 |
+
status: 'error',
|
| 76 |
+
message: 'Failed to fetch user',
|
| 77 |
+
error: err.message,
|
| 78 |
+
});
|
| 79 |
+
}
|
| 80 |
+
};
|
| 81 |
+
|
| 82 |
+
// Create user with specific role (admin only)
|
| 83 |
+
exports.createUser = async (req, res) => {
|
| 84 |
+
try {
|
| 85 |
+
const { name, email, phone, password, role, permissions, providerData } =
|
| 86 |
+
req.body;
|
| 87 |
+
|
| 88 |
+
// Validate required fields
|
| 89 |
+
if (!name || !email || !phone || !password) {
|
| 90 |
+
return res.status(400).json({
|
| 91 |
+
status: 'fail',
|
| 92 |
+
message: 'Please provide name, email, phone, and password',
|
| 93 |
+
});
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
// Check if email already exists
|
| 97 |
+
const existingUser = await User.findOne({ email });
|
| 98 |
+
if (existingUser) {
|
| 99 |
+
return res.status(400).json({
|
| 100 |
+
status: 'fail',
|
| 101 |
+
message: 'Email already registered',
|
| 102 |
+
});
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
// Check if phone already exists
|
| 106 |
+
const existingPhone = await User.findOne({ phone });
|
| 107 |
+
if (existingPhone) {
|
| 108 |
+
return res.status(400).json({
|
| 109 |
+
status: 'fail',
|
| 110 |
+
message: 'Phone number already registered',
|
| 111 |
+
});
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
// Create user data object
|
| 115 |
+
const userData = {
|
| 116 |
+
name,
|
| 117 |
+
email,
|
| 118 |
+
phone,
|
| 119 |
+
password,
|
| 120 |
+
role: role || 'customer',
|
| 121 |
+
};
|
| 122 |
+
|
| 123 |
+
// Add permissions if role is employee
|
| 124 |
+
if (role === 'employee' && permissions) {
|
| 125 |
+
userData.permissions = permissions;
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
// Create the user first
|
| 129 |
+
const newUser = await User.create(userData);
|
| 130 |
+
|
| 131 |
+
// If role is vendor, create or link provider
|
| 132 |
+
if (role === 'vendor' && providerData) {
|
| 133 |
+
const provider = await Provider.create({
|
| 134 |
+
...providerData,
|
| 135 |
+
user: newUser._id,
|
| 136 |
+
});
|
| 137 |
+
newUser.provider = provider._id;
|
| 138 |
+
await newUser.save();
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
// Remove password from response
|
| 142 |
+
newUser.password = undefined;
|
| 143 |
+
|
| 144 |
+
res.status(201).json({
|
| 145 |
+
status: 'success',
|
| 146 |
+
data: {
|
| 147 |
+
user: newUser,
|
| 148 |
+
},
|
| 149 |
+
});
|
| 150 |
+
} catch (err) {
|
| 151 |
+
res.status(500).json({
|
| 152 |
+
status: 'error',
|
| 153 |
+
message: 'Failed to create user',
|
| 154 |
+
error: err.message,
|
| 155 |
+
});
|
| 156 |
+
}
|
| 157 |
+
};
|
| 158 |
+
|
| 159 |
+
// Update user details (for employees)
|
| 160 |
+
exports.updateUser = async (req, res) => {
|
| 161 |
+
try {
|
| 162 |
+
const { name, email, phone, password, permissions, isActive } = req.body;
|
| 163 |
+
|
| 164 |
+
const user = await User.findById(req.params.id);
|
| 165 |
+
|
| 166 |
+
if (!user) {
|
| 167 |
+
return res.status(404).json({
|
| 168 |
+
status: 'fail',
|
| 169 |
+
message: 'User not found',
|
| 170 |
+
});
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
// Update basic fields if provided
|
| 174 |
+
if (name) user.name = name;
|
| 175 |
+
if (email) {
|
| 176 |
+
// Check if email is already taken by another user
|
| 177 |
+
const existingUser = await User.findOne({
|
| 178 |
+
email,
|
| 179 |
+
_id: { $ne: req.params.id },
|
| 180 |
+
});
|
| 181 |
+
if (existingUser) {
|
| 182 |
+
return res.status(400).json({
|
| 183 |
+
status: 'fail',
|
| 184 |
+
message: 'Email already in use',
|
| 185 |
+
});
|
| 186 |
+
}
|
| 187 |
+
user.email = email;
|
| 188 |
+
}
|
| 189 |
+
if (phone) {
|
| 190 |
+
// Check if phone is already taken by another user
|
| 191 |
+
const existingPhone = await User.findOne({
|
| 192 |
+
phone,
|
| 193 |
+
_id: { $ne: req.params.id },
|
| 194 |
+
});
|
| 195 |
+
if (existingPhone) {
|
| 196 |
+
return res.status(400).json({
|
| 197 |
+
status: 'fail',
|
| 198 |
+
message: 'Phone number already in use',
|
| 199 |
+
});
|
| 200 |
+
}
|
| 201 |
+
user.phone = phone;
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
// Update password if provided
|
| 205 |
+
if (password) {
|
| 206 |
+
user.password = password; // Will be hashed by pre-save middleware
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
// Update permissions if user is employee
|
| 210 |
+
if (user.role === 'employee' && permissions !== undefined) {
|
| 211 |
+
user.permissions = permissions;
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
// Update isActive status
|
| 215 |
+
if (isActive !== undefined) {
|
| 216 |
+
user.isActive = isActive;
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
await user.save();
|
| 220 |
+
|
| 221 |
+
// Remove password from response
|
| 222 |
+
user.password = undefined;
|
| 223 |
+
|
| 224 |
+
res.status(200).json({
|
| 225 |
+
status: 'success',
|
| 226 |
+
data: {
|
| 227 |
+
user,
|
| 228 |
+
},
|
| 229 |
+
});
|
| 230 |
+
} catch (err) {
|
| 231 |
+
res.status(500).json({
|
| 232 |
+
status: 'error',
|
| 233 |
+
message: 'Failed to update user',
|
| 234 |
+
error: err.message,
|
| 235 |
+
});
|
| 236 |
+
}
|
| 237 |
+
};
|
| 238 |
+
|
| 239 |
+
// Update user role
|
| 240 |
+
exports.updateUserRole = async (req, res) => {
|
| 241 |
+
try {
|
| 242 |
+
const { role } = req.body;
|
| 243 |
+
|
| 244 |
+
if (!role || !['customer', 'admin', 'employee', 'vendor'].includes(role)) {
|
| 245 |
+
return res.status(400).json({
|
| 246 |
+
status: 'fail',
|
| 247 |
+
message: 'Please provide a valid role',
|
| 248 |
+
});
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
const user = await User.findById(req.params.id);
|
| 252 |
+
|
| 253 |
+
if (!user) {
|
| 254 |
+
return res.status(404).json({
|
| 255 |
+
status: 'fail',
|
| 256 |
+
message: 'User not found',
|
| 257 |
+
});
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
user.role = role;
|
| 261 |
+
|
| 262 |
+
// Clear permissions if changing from employee to another role
|
| 263 |
+
if (role !== 'employee') {
|
| 264 |
+
user.permissions = [];
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
+
await user.save();
|
| 268 |
+
|
| 269 |
+
res.status(200).json({
|
| 270 |
+
status: 'success',
|
| 271 |
+
data: {
|
| 272 |
+
user,
|
| 273 |
+
},
|
| 274 |
+
});
|
| 275 |
+
} catch (err) {
|
| 276 |
+
res.status(500).json({
|
| 277 |
+
status: 'error',
|
| 278 |
+
message: 'Failed to update user role',
|
| 279 |
+
error: err.message,
|
| 280 |
+
});
|
| 281 |
+
}
|
| 282 |
+
};
|
| 283 |
+
|
| 284 |
+
// Update employee permissions
|
| 285 |
+
exports.updateEmployeePermissions = async (req, res) => {
|
| 286 |
+
try {
|
| 287 |
+
const { permissions } = req.body;
|
| 288 |
+
|
| 289 |
+
if (!Array.isArray(permissions)) {
|
| 290 |
+
return res.status(400).json({
|
| 291 |
+
status: 'fail',
|
| 292 |
+
message: 'Permissions must be an array',
|
| 293 |
+
});
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
const user = await User.findById(req.params.id);
|
| 297 |
+
|
| 298 |
+
if (!user) {
|
| 299 |
+
return res.status(404).json({
|
| 300 |
+
status: 'fail',
|
| 301 |
+
message: 'User not found',
|
| 302 |
+
});
|
| 303 |
+
}
|
| 304 |
+
|
| 305 |
+
if (user.role !== 'employee') {
|
| 306 |
+
return res.status(400).json({
|
| 307 |
+
status: 'fail',
|
| 308 |
+
message: 'Permissions can only be set for employees',
|
| 309 |
+
});
|
| 310 |
+
}
|
| 311 |
+
|
| 312 |
+
user.permissions = permissions;
|
| 313 |
+
await user.save();
|
| 314 |
+
|
| 315 |
+
// Emit socket event to notify the employee of permission changes
|
| 316 |
+
try {
|
| 317 |
+
const io = require('../utils/socket').getIO();
|
| 318 |
+
io.to(user._id.toString()).emit('permissionsUpdated', {
|
| 319 |
+
permissions: user.permissions,
|
| 320 |
+
message: 'تم تحديث صلاحياتك من قبل المسؤول',
|
| 321 |
+
});
|
| 322 |
+
} catch (socketError) {
|
| 323 |
+
console.error('Failed to emit permission update:', socketError);
|
| 324 |
+
// Don't fail the request if socket emission fails
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
res.status(200).json({
|
| 328 |
+
status: 'success',
|
| 329 |
+
data: {
|
| 330 |
+
user,
|
| 331 |
+
},
|
| 332 |
+
});
|
| 333 |
+
} catch (err) {
|
| 334 |
+
res.status(500).json({
|
| 335 |
+
status: 'error',
|
| 336 |
+
message: 'Failed to update permissions',
|
| 337 |
+
error: err.message,
|
| 338 |
+
});
|
| 339 |
+
}
|
| 340 |
+
};
|
| 341 |
+
|
| 342 |
+
// Update vendor information
|
| 343 |
+
exports.updateVendorInfo = async (req, res) => {
|
| 344 |
+
try {
|
| 345 |
+
const { vendorInfo } = req.body;
|
| 346 |
+
|
| 347 |
+
if (!vendorInfo) {
|
| 348 |
+
return res.status(400).json({
|
| 349 |
+
status: 'fail',
|
| 350 |
+
message: 'Please provide vendor information',
|
| 351 |
+
});
|
| 352 |
+
}
|
| 353 |
+
|
| 354 |
+
const user = await User.findById(req.params.id);
|
| 355 |
+
|
| 356 |
+
if (!user) {
|
| 357 |
+
return res.status(404).json({
|
| 358 |
+
status: 'fail',
|
| 359 |
+
message: 'User not found',
|
| 360 |
+
});
|
| 361 |
+
}
|
| 362 |
+
|
| 363 |
+
if (user.role !== 'vendor') {
|
| 364 |
+
return res.status(400).json({
|
| 365 |
+
status: 'fail',
|
| 366 |
+
message: 'Vendor info can only be set for vendors',
|
| 367 |
+
});
|
| 368 |
+
}
|
| 369 |
+
|
| 370 |
+
user.vendorInfo = { ...user.vendorInfo, ...vendorInfo };
|
| 371 |
+
await user.save();
|
| 372 |
+
|
| 373 |
+
res.status(200).json({
|
| 374 |
+
status: 'success',
|
| 375 |
+
data: {
|
| 376 |
+
user,
|
| 377 |
+
},
|
| 378 |
+
});
|
| 379 |
+
} catch (err) {
|
| 380 |
+
res.status(500).json({
|
| 381 |
+
status: 'error',
|
| 382 |
+
message: 'Failed to update vendor info',
|
| 383 |
+
error: err.message,
|
| 384 |
+
});
|
| 385 |
+
}
|
| 386 |
+
};
|
| 387 |
+
|
| 388 |
+
// Deactivate user account
|
| 389 |
+
exports.deactivateUser = async (req, res) => {
|
| 390 |
+
try {
|
| 391 |
+
const user = await User.findById(req.params.id);
|
| 392 |
+
|
| 393 |
+
if (!user) {
|
| 394 |
+
return res.status(404).json({
|
| 395 |
+
status: 'fail',
|
| 396 |
+
message: 'User not found',
|
| 397 |
+
});
|
| 398 |
+
}
|
| 399 |
+
|
| 400 |
+
user.isActive = false;
|
| 401 |
+
await user.save();
|
| 402 |
+
|
| 403 |
+
res.status(200).json({
|
| 404 |
+
status: 'success',
|
| 405 |
+
message: 'User account deactivated successfully',
|
| 406 |
+
data: {
|
| 407 |
+
user,
|
| 408 |
+
},
|
| 409 |
+
});
|
| 410 |
+
} catch (err) {
|
| 411 |
+
res.status(500).json({
|
| 412 |
+
status: 'error',
|
| 413 |
+
message: 'Failed to deactivate user',
|
| 414 |
+
error: err.message,
|
| 415 |
+
});
|
| 416 |
+
}
|
| 417 |
+
};
|
| 418 |
+
|
| 419 |
+
// Activate user account
|
| 420 |
+
exports.activateUser = async (req, res) => {
|
| 421 |
+
try {
|
| 422 |
+
const user = await User.findById(req.params.id);
|
| 423 |
+
|
| 424 |
+
if (!user) {
|
| 425 |
+
return res.status(404).json({
|
| 426 |
+
status: 'fail',
|
| 427 |
+
message: 'User not found',
|
| 428 |
+
});
|
| 429 |
+
}
|
| 430 |
+
|
| 431 |
+
user.isActive = true;
|
| 432 |
+
await user.save();
|
| 433 |
+
|
| 434 |
+
res.status(200).json({
|
| 435 |
+
status: 'success',
|
| 436 |
+
message: 'User account activated successfully',
|
| 437 |
+
data: {
|
| 438 |
+
user,
|
| 439 |
+
},
|
| 440 |
+
});
|
| 441 |
+
} catch (err) {
|
| 442 |
+
res.status(500).json({
|
| 443 |
+
status: 'error',
|
| 444 |
+
message: 'Failed to activate user',
|
| 445 |
+
error: err.message,
|
| 446 |
+
});
|
| 447 |
+
}
|
| 448 |
+
};
|
| 449 |
+
|
| 450 |
+
// Delete user (permanent)
|
| 451 |
+
exports.deleteUser = async (req, res) => {
|
| 452 |
+
try {
|
| 453 |
+
const user = await User.findByIdAndDelete(req.params.id);
|
| 454 |
+
|
| 455 |
+
if (!user) {
|
| 456 |
+
return res.status(404).json({
|
| 457 |
+
status: 'fail',
|
| 458 |
+
message: 'User not found',
|
| 459 |
+
});
|
| 460 |
+
}
|
| 461 |
+
|
| 462 |
+
res.status(200).json({
|
| 463 |
+
status: 'success',
|
| 464 |
+
message: 'User deleted successfully',
|
| 465 |
+
});
|
| 466 |
+
} catch (err) {
|
| 467 |
+
res.status(500).json({
|
| 468 |
+
status: 'error',
|
| 469 |
+
message: 'Failed to delete user',
|
| 470 |
+
error: err.message,
|
| 471 |
+
});
|
| 472 |
+
}
|
| 473 |
+
};
|
controllers/usersController.js
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const User = require('../models/userModel');
|
| 2 |
+
const Order = require('../models/orderModel');
|
| 3 |
+
|
| 4 |
+
// Get all users with their order statistics
|
| 5 |
+
exports.getAllUsers = async (req, res) => {
|
| 6 |
+
try {
|
| 7 |
+
// Get all users sorted by registration date (newest first)
|
| 8 |
+
const users = await User.find()
|
| 9 |
+
.select('-password')
|
| 10 |
+
.sort({ createdAt: -1 })
|
| 11 |
+
.lean();
|
| 12 |
+
|
| 13 |
+
// Get current month date range
|
| 14 |
+
const now = new Date();
|
| 15 |
+
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
| 16 |
+
const endOfMonth = new Date(
|
| 17 |
+
now.getFullYear(),
|
| 18 |
+
now.getMonth() + 1,
|
| 19 |
+
0,
|
| 20 |
+
23,
|
| 21 |
+
59,
|
| 22 |
+
59,
|
| 23 |
+
);
|
| 24 |
+
|
| 25 |
+
// Get order counts for each user
|
| 26 |
+
const usersWithStats = await Promise.all(
|
| 27 |
+
users.map(async (user) => {
|
| 28 |
+
// Count total orders for this user
|
| 29 |
+
const totalOrders = await Order.countDocuments({ user: user._id });
|
| 30 |
+
|
| 31 |
+
// Count orders this month
|
| 32 |
+
const ordersThisMonth = await Order.countDocuments({
|
| 33 |
+
user: user._id,
|
| 34 |
+
createdAt: { $gte: startOfMonth, $lte: endOfMonth },
|
| 35 |
+
});
|
| 36 |
+
|
| 37 |
+
// Calculate total spent
|
| 38 |
+
const orders = await Order.find({ user: user._id });
|
| 39 |
+
const totalSpent = orders.reduce(
|
| 40 |
+
(sum, order) => sum + order.totalPrice,
|
| 41 |
+
0,
|
| 42 |
+
);
|
| 43 |
+
|
| 44 |
+
// Calculate tier based on order count
|
| 45 |
+
let tier = 'حديدي'; // Iron (default for 0 orders)
|
| 46 |
+
if (totalOrders >= 35) {
|
| 47 |
+
tier = 'بلاتيني'; // Platinum
|
| 48 |
+
} else if (totalOrders >= 20) {
|
| 49 |
+
tier = 'ذهبي'; // Gold
|
| 50 |
+
} else if (totalOrders >= 10) {
|
| 51 |
+
tier = 'فضي'; // Silver
|
| 52 |
+
} else if (totalOrders >= 1) {
|
| 53 |
+
tier = 'برونزي'; // Bronze
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
// isActive is true if user has orders this month
|
| 57 |
+
const isActive = ordersThisMonth > 0;
|
| 58 |
+
|
| 59 |
+
return {
|
| 60 |
+
...user,
|
| 61 |
+
totalOrders,
|
| 62 |
+
totalSpent,
|
| 63 |
+
tier,
|
| 64 |
+
isActive,
|
| 65 |
+
};
|
| 66 |
+
}),
|
| 67 |
+
);
|
| 68 |
+
|
| 69 |
+
res.status(200).json({
|
| 70 |
+
status: 'success',
|
| 71 |
+
results: usersWithStats.length,
|
| 72 |
+
data: usersWithStats,
|
| 73 |
+
});
|
| 74 |
+
} catch (err) {
|
| 75 |
+
console.error('Error fetching users:', err);
|
| 76 |
+
res.status(500).json({
|
| 77 |
+
status: 'fail',
|
| 78 |
+
message: 'Failed to fetch users',
|
| 79 |
+
error: err.message,
|
| 80 |
+
});
|
| 81 |
+
}
|
| 82 |
+
};
|
| 83 |
+
|
| 84 |
+
// Get user by ID with their orders
|
| 85 |
+
exports.getUserById = async (req, res) => {
|
| 86 |
+
try {
|
| 87 |
+
const { id } = req.params;
|
| 88 |
+
|
| 89 |
+
// Find user by ID
|
| 90 |
+
const user = await User.findById(id).select('-password').lean();
|
| 91 |
+
|
| 92 |
+
if (!user) {
|
| 93 |
+
return res.status(404).json({
|
| 94 |
+
status: 'fail',
|
| 95 |
+
message: 'User not found',
|
| 96 |
+
});
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
// Get all orders for this user
|
| 100 |
+
const orders = await Order.find({ user: id })
|
| 101 |
+
.populate('items.product', 'nameAr nameEn price images')
|
| 102 |
+
.populate('promo', 'code discountType discountValue')
|
| 103 |
+
.sort({ createdAt: -1 })
|
| 104 |
+
.lean();
|
| 105 |
+
|
| 106 |
+
// Get current month date range
|
| 107 |
+
const now = new Date();
|
| 108 |
+
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
| 109 |
+
const endOfMonth = new Date(
|
| 110 |
+
now.getFullYear(),
|
| 111 |
+
now.getMonth() + 1,
|
| 112 |
+
0,
|
| 113 |
+
23,
|
| 114 |
+
59,
|
| 115 |
+
59,
|
| 116 |
+
);
|
| 117 |
+
|
| 118 |
+
// Count total orders
|
| 119 |
+
const totalOrders = orders.length;
|
| 120 |
+
|
| 121 |
+
// Count orders this month
|
| 122 |
+
const ordersThisMonth = orders.filter(
|
| 123 |
+
(order) =>
|
| 124 |
+
order.createdAt >= startOfMonth && order.createdAt <= endOfMonth,
|
| 125 |
+
).length;
|
| 126 |
+
|
| 127 |
+
// Calculate total spent
|
| 128 |
+
const totalSpent = orders.reduce((sum, order) => sum + order.totalPrice, 0);
|
| 129 |
+
|
| 130 |
+
// Calculate tier based on order count
|
| 131 |
+
let tier = 'حديدي'; // Iron (default for 0 orders)
|
| 132 |
+
if (totalOrders >= 35) {
|
| 133 |
+
tier = 'بلاتيني'; // Platinum
|
| 134 |
+
} else if (totalOrders >= 20) {
|
| 135 |
+
tier = 'ذهبي'; // Gold
|
| 136 |
+
} else if (totalOrders >= 10) {
|
| 137 |
+
tier = 'فضي'; // Silver
|
| 138 |
+
} else if (totalOrders >= 1) {
|
| 139 |
+
tier = 'برونزي'; // Bronze
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
// isActive is true if user has orders this month
|
| 143 |
+
const isActive = ordersThisMonth > 0;
|
| 144 |
+
|
| 145 |
+
res.status(200).json({
|
| 146 |
+
status: 'success',
|
| 147 |
+
data: {
|
| 148 |
+
user: {
|
| 149 |
+
...user,
|
| 150 |
+
totalOrders,
|
| 151 |
+
totalSpent,
|
| 152 |
+
tier,
|
| 153 |
+
isActive,
|
| 154 |
+
},
|
| 155 |
+
orders,
|
| 156 |
+
},
|
| 157 |
+
});
|
| 158 |
+
} catch (err) {
|
| 159 |
+
console.error('Error fetching user by ID:', err);
|
| 160 |
+
res.status(500).json({
|
| 161 |
+
status: 'fail',
|
| 162 |
+
message: 'Failed to fetch user details',
|
| 163 |
+
error: err.message,
|
| 164 |
+
});
|
| 165 |
+
}
|
| 166 |
+
};
|
| 167 |
+
|
| 168 |
+
// Delete a user
|
| 169 |
+
exports.deleteUser = async (req, res) => {
|
| 170 |
+
try {
|
| 171 |
+
const user = await User.findByIdAndDelete(req.params.id);
|
| 172 |
+
|
| 173 |
+
if (!user) {
|
| 174 |
+
return res.status(404).json({
|
| 175 |
+
status: 'fail',
|
| 176 |
+
message: 'User not found',
|
| 177 |
+
});
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
res.status(204).json({
|
| 181 |
+
status: 'success',
|
| 182 |
+
data: null,
|
| 183 |
+
});
|
| 184 |
+
} catch (err) {
|
| 185 |
+
res.status(500).json({
|
| 186 |
+
status: 'fail',
|
| 187 |
+
message: 'Failed to delete user',
|
| 188 |
+
error: err.message,
|
| 189 |
+
});
|
| 190 |
+
}
|
| 191 |
+
};
|
controllers/vendorRequestController.js
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const VendorRequest = require('../models/vendorRequestModel');
|
| 2 |
+
const Provider = require('../models/providerModel');
|
| 3 |
+
const User = require('../models/userModel');
|
| 4 |
+
const {
|
| 5 |
+
sendVendorAcceptanceEmail,
|
| 6 |
+
sendVendorRejectionEmail,
|
| 7 |
+
} = require('../utils/emailService');
|
| 8 |
+
const { deleteImage, getPublicIdFromUrl } = require('../config/cloudinary');
|
| 9 |
+
|
| 10 |
+
// 1. Submit Vendor Request (Public)
|
| 11 |
+
exports.submitRequest = async (req, res) => {
|
| 12 |
+
try {
|
| 13 |
+
const requestData = { ...req.body };
|
| 14 |
+
if (req.file) {
|
| 15 |
+
requestData.logo = req.file.path;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
const newRequest = await VendorRequest.create(requestData);
|
| 19 |
+
|
| 20 |
+
res.status(201).json({
|
| 21 |
+
status: 'success',
|
| 22 |
+
message: 'Form submitted successfully',
|
| 23 |
+
data: { request: newRequest },
|
| 24 |
+
});
|
| 25 |
+
} catch (err) {
|
| 26 |
+
res.status(400).json({
|
| 27 |
+
status: 'fail',
|
| 28 |
+
message: err.message,
|
| 29 |
+
});
|
| 30 |
+
}
|
| 31 |
+
};
|
| 32 |
+
|
| 33 |
+
// 2. Get All Requests (Admin)
|
| 34 |
+
exports.getAllRequests = async (req, res) => {
|
| 35 |
+
try {
|
| 36 |
+
const requests = await VendorRequest.find().sort({ createdAt: -1 });
|
| 37 |
+
res.status(200).json({
|
| 38 |
+
status: 'success',
|
| 39 |
+
results: requests.length,
|
| 40 |
+
data: { requests },
|
| 41 |
+
});
|
| 42 |
+
} catch (err) {
|
| 43 |
+
res.status(500).json({
|
| 44 |
+
status: 'fail',
|
| 45 |
+
message: err.message,
|
| 46 |
+
});
|
| 47 |
+
}
|
| 48 |
+
};
|
| 49 |
+
|
| 50 |
+
// 3. Get Single Request (Admin)
|
| 51 |
+
exports.getRequest = async (req, res) => {
|
| 52 |
+
try {
|
| 53 |
+
const request = await VendorRequest.findById(req.params.id);
|
| 54 |
+
if (!request) {
|
| 55 |
+
return res.status(404).json({
|
| 56 |
+
status: 'fail',
|
| 57 |
+
message: 'No request found with that ID',
|
| 58 |
+
});
|
| 59 |
+
}
|
| 60 |
+
res.status(200).json({
|
| 61 |
+
status: 'success',
|
| 62 |
+
data: { request },
|
| 63 |
+
});
|
| 64 |
+
} catch (err) {
|
| 65 |
+
res.status(400).json({
|
| 66 |
+
status: 'fail',
|
| 67 |
+
message: err.message,
|
| 68 |
+
});
|
| 69 |
+
}
|
| 70 |
+
};
|
| 71 |
+
|
| 72 |
+
// 4. Delete Request (Admin)
|
| 73 |
+
exports.deleteRequest = async (req, res) => {
|
| 74 |
+
try {
|
| 75 |
+
const request = await VendorRequest.findById(req.params.id);
|
| 76 |
+
if (!request) {
|
| 77 |
+
return res.status(404).json({
|
| 78 |
+
status: 'fail',
|
| 79 |
+
message: 'No request found with that ID',
|
| 80 |
+
});
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
// Delete logo from Cloudinary if it exists
|
| 84 |
+
if (request.logo) {
|
| 85 |
+
const publicId = getPublicIdFromUrl(request.logo);
|
| 86 |
+
if (publicId) await deleteImage(publicId).catch(() => {});
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
await VendorRequest.findByIdAndDelete(req.params.id);
|
| 90 |
+
|
| 91 |
+
res.status(204).json({
|
| 92 |
+
status: 'success',
|
| 93 |
+
data: null,
|
| 94 |
+
});
|
| 95 |
+
} catch (err) {
|
| 96 |
+
res.status(400).json({
|
| 97 |
+
status: 'fail',
|
| 98 |
+
message: err.message,
|
| 99 |
+
});
|
| 100 |
+
}
|
| 101 |
+
};
|
| 102 |
+
|
| 103 |
+
// 5. Respond to Request (Admin)
|
| 104 |
+
// This will handle both Accept and Refuse
|
| 105 |
+
exports.respondToRequest = async (req, res) => {
|
| 106 |
+
try {
|
| 107 |
+
const { status, password } = req.body;
|
| 108 |
+
const request = await VendorRequest.findById(req.params.id);
|
| 109 |
+
|
| 110 |
+
if (!request) {
|
| 111 |
+
return res.status(404).json({
|
| 112 |
+
status: 'fail',
|
| 113 |
+
message: 'No request found with that ID',
|
| 114 |
+
});
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
if (status === 'accepted') {
|
| 118 |
+
// Create Provider Account (Logic similar to AdminProviderAdd.jsx handles it on frontend, but we need backend logic too)
|
| 119 |
+
// Actually, the user said: "if i accept it uses the info in the form and give me option to assign his password"
|
| 120 |
+
// So the admin will submit a final form with password.
|
| 121 |
+
|
| 122 |
+
// First check if user with same email or phone exists
|
| 123 |
+
const existingUser = await User.findOne({
|
| 124 |
+
$or: [{ email: request.email }, { phone: request.phoneNumber }],
|
| 125 |
+
});
|
| 126 |
+
if (existingUser) {
|
| 127 |
+
const field =
|
| 128 |
+
existingUser.email === request.email
|
| 129 |
+
? 'البريد الإلكتروني'
|
| 130 |
+
: 'رقم الهاتف';
|
| 131 |
+
return res.status(400).json({
|
| 132 |
+
status: 'fail',
|
| 133 |
+
message: `${field} مسجل بالفعل لمستخدم آخر`,
|
| 134 |
+
});
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
// Get last provider ID for auto-increment
|
| 138 |
+
const lastProvider = await Provider.findOne().sort({ id: -1 });
|
| 139 |
+
const autoId = lastProvider ? lastProvider.id + 1 : 1;
|
| 140 |
+
|
| 141 |
+
// 1. Create Provider document (without user link yet)
|
| 142 |
+
const newProvider = await Provider.create({
|
| 143 |
+
id: autoId,
|
| 144 |
+
name: request.ownerName,
|
| 145 |
+
storeName: request.businessName,
|
| 146 |
+
phoneNumber: request.phoneNumber,
|
| 147 |
+
address: request.address,
|
| 148 |
+
logo: request.logo,
|
| 149 |
+
});
|
| 150 |
+
|
| 151 |
+
// 2. Create User account for vendor and link to provider
|
| 152 |
+
try {
|
| 153 |
+
const newUser = await User.create({
|
| 154 |
+
name: request.ownerName,
|
| 155 |
+
email: request.email,
|
| 156 |
+
phone: request.phoneNumber,
|
| 157 |
+
password: password,
|
| 158 |
+
role: 'vendor',
|
| 159 |
+
provider: newProvider._id,
|
| 160 |
+
});
|
| 161 |
+
|
| 162 |
+
// 3. Update provider with user reference
|
| 163 |
+
newProvider.user = newUser._id;
|
| 164 |
+
await newProvider.save();
|
| 165 |
+
|
| 166 |
+
// 4. Update Request Status
|
| 167 |
+
request.status = 'accepted';
|
| 168 |
+
request.reviewedAt = Date.now();
|
| 169 |
+
await request.save();
|
| 170 |
+
|
| 171 |
+
// 5. Send Acceptance Email
|
| 172 |
+
await sendVendorAcceptanceEmail({
|
| 173 |
+
email: request.email,
|
| 174 |
+
name: request.ownerName,
|
| 175 |
+
storeName: request.businessName,
|
| 176 |
+
password: password,
|
| 177 |
+
});
|
| 178 |
+
|
| 179 |
+
return res.status(200).json({
|
| 180 |
+
status: 'success',
|
| 181 |
+
message: 'Request accepted and provider created',
|
| 182 |
+
data: { provider: newProvider },
|
| 183 |
+
});
|
| 184 |
+
} catch (err) {
|
| 185 |
+
// Rollback: Delete the created provider if user creation fails
|
| 186 |
+
await Provider.findByIdAndDelete(newProvider._id);
|
| 187 |
+
throw err;
|
| 188 |
+
}
|
| 189 |
+
} else if (status === 'rejected') {
|
| 190 |
+
request.status = 'rejected';
|
| 191 |
+
request.reviewedAt = Date.now();
|
| 192 |
+
await request.save();
|
| 193 |
+
|
| 194 |
+
// Send Rejection Email
|
| 195 |
+
await sendVendorRejectionEmail({
|
| 196 |
+
email: request.email,
|
| 197 |
+
name: request.ownerName,
|
| 198 |
+
storeName: request.businessName,
|
| 199 |
+
});
|
| 200 |
+
|
| 201 |
+
return res.status(200).json({
|
| 202 |
+
status: 'success',
|
| 203 |
+
message: 'Request rejected',
|
| 204 |
+
});
|
| 205 |
+
}
|
| 206 |
+
} catch (err) {
|
| 207 |
+
res.status(400).json({
|
| 208 |
+
status: 'fail',
|
| 209 |
+
message: err.message,
|
| 210 |
+
});
|
| 211 |
+
}
|
| 212 |
+
};
|
controllers/visitController.js
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const Visit = require('../models/visitModel');
|
| 2 |
+
|
| 3 |
+
// Track a visit
|
| 4 |
+
exports.trackVisit = async (req, res) => {
|
| 5 |
+
try {
|
| 6 |
+
const { sessionId, page, userId } = req.body;
|
| 7 |
+
|
| 8 |
+
if (!sessionId) {
|
| 9 |
+
return res.status(400).json({
|
| 10 |
+
status: 'fail',
|
| 11 |
+
message: 'Session ID is required',
|
| 12 |
+
});
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
// Get IP address from request
|
| 16 |
+
const xForwardedFor = req.headers['x-forwarded-for'];
|
| 17 |
+
const ipAddress =
|
| 18 |
+
(xForwardedFor ? xForwardedFor.split(',')[0] : null) ||
|
| 19 |
+
req.connection.remoteAddress ||
|
| 20 |
+
req.socket.remoteAddress ||
|
| 21 |
+
req.ip;
|
| 22 |
+
|
| 23 |
+
// Get user agent
|
| 24 |
+
const userAgent = req.headers['user-agent'] || '';
|
| 25 |
+
|
| 26 |
+
// Grouping priority:
|
| 27 |
+
// 1. If we have a userId, we find the ONE global record for this user
|
| 28 |
+
// 2. If no userId (guest), we find a recent record for this sessionId
|
| 29 |
+
const today = new Date();
|
| 30 |
+
today.setHours(0, 0, 0, 0);
|
| 31 |
+
|
| 32 |
+
const query = { createdAt: { $gte: today } };
|
| 33 |
+
if (userId) {
|
| 34 |
+
query.user = userId;
|
| 35 |
+
} else {
|
| 36 |
+
query.sessionId = sessionId;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
let visit = await Visit.findOne(query).populate('user', 'name email');
|
| 40 |
+
|
| 41 |
+
if (visit) {
|
| 42 |
+
// Grouping: Increment count and update last active time
|
| 43 |
+
visit.count += 1;
|
| 44 |
+
visit.lastVisitedAt = new Date();
|
| 45 |
+
// Update user ID if it was null before (guest just logged in)
|
| 46 |
+
if (!visit.user && userId) {
|
| 47 |
+
visit.user = userId;
|
| 48 |
+
}
|
| 49 |
+
// Update IP and User Agent to latest
|
| 50 |
+
visit.ipAddress = ipAddress;
|
| 51 |
+
visit.userAgent = userAgent;
|
| 52 |
+
visit.page = page || '/';
|
| 53 |
+
await visit.save();
|
| 54 |
+
} else {
|
| 55 |
+
// Create new visit document
|
| 56 |
+
visit = await Visit.create({
|
| 57 |
+
user: userId || null,
|
| 58 |
+
ipAddress,
|
| 59 |
+
userAgent,
|
| 60 |
+
page: page || '/',
|
| 61 |
+
sessionId,
|
| 62 |
+
count: 1,
|
| 63 |
+
lastVisitedAt: new Date(),
|
| 64 |
+
});
|
| 65 |
+
// Populate if it was a user
|
| 66 |
+
if (userId) {
|
| 67 |
+
await visit.populate('user', 'name email');
|
| 68 |
+
}
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
res.status(200).json({
|
| 72 |
+
status: 'success',
|
| 73 |
+
data: { visit },
|
| 74 |
+
});
|
| 75 |
+
} catch (err) {
|
| 76 |
+
res.status(500).json({
|
| 77 |
+
status: 'fail',
|
| 78 |
+
message: err.message,
|
| 79 |
+
});
|
| 80 |
+
}
|
| 81 |
+
};
|
| 82 |
+
|
| 83 |
+
// Get visit statistics
|
| 84 |
+
exports.getVisitStats = async (req, res) => {
|
| 85 |
+
try {
|
| 86 |
+
const now = new Date();
|
| 87 |
+
|
| 88 |
+
// Today's start
|
| 89 |
+
const todayStart = new Date(
|
| 90 |
+
now.getFullYear(),
|
| 91 |
+
now.getMonth(),
|
| 92 |
+
now.getDate(),
|
| 93 |
+
);
|
| 94 |
+
|
| 95 |
+
// Current month boundaries
|
| 96 |
+
const currentMonthStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
| 97 |
+
const currentMonthEnd = new Date(
|
| 98 |
+
now.getFullYear(),
|
| 99 |
+
now.getMonth() + 1,
|
| 100 |
+
0,
|
| 101 |
+
23,
|
| 102 |
+
59,
|
| 103 |
+
59,
|
| 104 |
+
);
|
| 105 |
+
|
| 106 |
+
// Previous month boundaries
|
| 107 |
+
const previousMonthStart = new Date(
|
| 108 |
+
now.getFullYear(),
|
| 109 |
+
now.getMonth() - 1,
|
| 110 |
+
1,
|
| 111 |
+
);
|
| 112 |
+
const previousMonthEnd = new Date(
|
| 113 |
+
now.getFullYear(),
|
| 114 |
+
now.getMonth(),
|
| 115 |
+
0,
|
| 116 |
+
23,
|
| 117 |
+
59,
|
| 118 |
+
59,
|
| 119 |
+
);
|
| 120 |
+
|
| 121 |
+
// Helper for summing counts and counting documents
|
| 122 |
+
const getStats = async (filter = {}) => {
|
| 123 |
+
const uniqueVisits = await Visit.countDocuments(filter);
|
| 124 |
+
const totalResult = await Visit.aggregate([
|
| 125 |
+
{ $match: filter },
|
| 126 |
+
{ $group: { _id: null, total: { $sum: '$count' } } },
|
| 127 |
+
]);
|
| 128 |
+
const totalVisits = totalResult.length > 0 ? totalResult[0].total : 0;
|
| 129 |
+
return { uniqueVisits, totalVisits };
|
| 130 |
+
};
|
| 131 |
+
|
| 132 |
+
// Get all time stats
|
| 133 |
+
const allTime = await getStats();
|
| 134 |
+
|
| 135 |
+
// Get today's stats
|
| 136 |
+
const today = await getStats({ createdAt: { $gte: todayStart } });
|
| 137 |
+
|
| 138 |
+
// Get monthly stats for growth calculation
|
| 139 |
+
const currentMonth = await getStats({
|
| 140 |
+
createdAt: { $gte: currentMonthStart, $lte: currentMonthEnd },
|
| 141 |
+
});
|
| 142 |
+
const previousMonth = await getStats({
|
| 143 |
+
createdAt: { $gte: previousMonthStart, $lte: previousMonthEnd },
|
| 144 |
+
});
|
| 145 |
+
|
| 146 |
+
// Calculate monthly growth percentage based on TOTAL visits
|
| 147 |
+
let monthlyGrowth = 0;
|
| 148 |
+
if (previousMonth.totalVisits > 0) {
|
| 149 |
+
monthlyGrowth =
|
| 150 |
+
((currentMonth.totalVisits - previousMonth.totalVisits) /
|
| 151 |
+
previousMonth.totalVisits) *
|
| 152 |
+
100;
|
| 153 |
+
} else if (currentMonth.totalVisits > 0) {
|
| 154 |
+
monthlyGrowth = 100;
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
res.status(200).json({
|
| 158 |
+
status: 'success',
|
| 159 |
+
data: {
|
| 160 |
+
totalVisits: allTime.totalVisits,
|
| 161 |
+
uniqueVisits: allTime.uniqueVisits,
|
| 162 |
+
todayVisits: today.totalVisits,
|
| 163 |
+
todayUnique: today.uniqueVisits,
|
| 164 |
+
currentMonthVisits: currentMonth.totalVisits,
|
| 165 |
+
previousMonthVisits: previousMonth.totalVisits,
|
| 166 |
+
monthlyGrowth: parseFloat(monthlyGrowth.toFixed(1)),
|
| 167 |
+
},
|
| 168 |
+
});
|
| 169 |
+
} catch (err) {
|
| 170 |
+
res.status(500).json({
|
| 171 |
+
status: 'fail',
|
| 172 |
+
message: err.message,
|
| 173 |
+
});
|
| 174 |
+
}
|
| 175 |
+
};
|
| 176 |
+
|
| 177 |
+
// Get all visits (admin only)
|
| 178 |
+
exports.getAllVisits = async (req, res) => {
|
| 179 |
+
try {
|
| 180 |
+
const { page = 1, limit = 50, startDate, endDate } = req.query;
|
| 181 |
+
|
| 182 |
+
// Build filter
|
| 183 |
+
const filter = {};
|
| 184 |
+
if (startDate || endDate) {
|
| 185 |
+
filter.createdAt = {};
|
| 186 |
+
if (startDate) filter.createdAt.$gte = new Date(startDate);
|
| 187 |
+
if (endDate) filter.createdAt.$lte = new Date(endDate);
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
const visits = await Visit.find(filter)
|
| 191 |
+
.populate('user', 'name email')
|
| 192 |
+
.sort({ createdAt: -1 })
|
| 193 |
+
.limit(limit * 1)
|
| 194 |
+
.skip((page - 1) * limit);
|
| 195 |
+
|
| 196 |
+
const count = await Visit.countDocuments(filter);
|
| 197 |
+
|
| 198 |
+
res.status(200).json({
|
| 199 |
+
status: 'success',
|
| 200 |
+
results: visits.length,
|
| 201 |
+
totalPages: Math.ceil(count / limit),
|
| 202 |
+
currentPage: page,
|
| 203 |
+
data: { visits },
|
| 204 |
+
});
|
| 205 |
+
} catch (err) {
|
| 206 |
+
res.status(500).json({
|
| 207 |
+
status: 'fail',
|
| 208 |
+
message: err.message,
|
| 209 |
+
});
|
| 210 |
+
}
|
| 211 |
+
};
|
devData/.DS_Store
ADDED
|
Binary file (6.15 kB). View file
|
|
|
devData/brands.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{ "id": 1, "nameEn": "Fit" },
|
| 3 |
+
{ "id": 7, "nameEn": "Max" },
|
| 4 |
+
{ "id": 5, "nameEn": "LORRAIN" },
|
| 5 |
+
{ "id": 3, "nameEn": "Hans" },
|
| 6 |
+
{ "id": 11, "nameEn": "Annovi" },
|
| 7 |
+
{ "id": 13, "nameEn": "Bisso" },
|
| 8 |
+
{ "id": 15, "nameEn": "durmiri" },
|
| 9 |
+
{ "id": 16, "nameEn": "Remo" },
|
| 10 |
+
{ "id": 17, "nameEn": "Total" },
|
| 11 |
+
{ "id": 18, "nameEn": "Ingco" },
|
| 12 |
+
{ "id": 19, "nameEn": "Dyllu" },
|
| 13 |
+
{ "id": 20, "nameEn": "DCA" },
|
| 14 |
+
{ "id": 21, "nameEn": "brennenstuhl" },
|
| 15 |
+
{ "id": 22, "nameEn": "WOKIN" },
|
| 16 |
+
{ "id": 23, "nameEn": "HARDEN" },
|
| 17 |
+
{ "id": 24, "nameEn": "PIT" },
|
| 18 |
+
{ "id": 25, "nameEn": "UNI-T" },
|
| 19 |
+
{ "id": 26, "nameEn": "RONIX" },
|
| 20 |
+
{ "id": 27, "nameEn": "Steco" }
|
| 21 |
+
]
|
devData/broken-images.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
[]
|
devData/categories.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{ "id": 1, "nameAr": "عدد كهربائيه", "nameEn": "Electrical Tools", "parentName": null },
|
| 3 |
+
{ "id": 2, "nameAr": "عدد هواء", "nameEn": "Air Tools", "parentName": null },
|
| 4 |
+
{ "id": 3, "nameAr": "عدد يدوية", "nameEn": "Hand Tools", "parentName": null },
|
| 5 |
+
{ "id": 4, "nameAr": "عدد لحام", "nameEn": "Welding Tools", "parentName": null },
|
| 6 |
+
{ "id": 5, "nameAr": "عدد سمكرة", "nameEn": "Body Tools", "parentName": null },
|
| 7 |
+
{ "id": 8, "nameAr": "ادوات قياس", "nameEn": "Measuring Tools", "parentName": null },
|
| 8 |
+
{ "id": 9, "nameAr": "شنيور بطارية", "nameEn": "Cordless Drill", "parentName": "عدد كهربائيه" },
|
| 9 |
+
{ "id": 11, "nameAr": "كمبروسر ولاعة", "nameEn": "Mini Air Compressor", "parentName": "عدد هواء" },
|
| 10 |
+
{ "id": 12, "nameAr": "ميني كرافت", "nameEn": "Mini Craft", "parentName": "عدد كهربائيه" },
|
| 11 |
+
{ "id": 13, "nameAr": "صواريخ قطعية", "nameEn": "Angle Grinders", "parentName": "عدد كهربائيه" },
|
| 12 |
+
{ "id": 14, "nameAr": "كوريك", "nameEn": "Jack", "parentName": "عدد يدوية" },
|
| 13 |
+
{ "id": 15, "nameAr": "هيلتي", "nameEn": "Demolition Hammer", "parentName": "عدد كهربائيه" },
|
| 14 |
+
{ "id": 16, "nameAr": "بنس برشام", "nameEn": "Riveter Tools", "parentName": "عدد يدوية" },
|
| 15 |
+
{ "id": 17, "nameAr": "عدد ميكانيكا", "nameEn": "Mechanical Tools", "parentName": "عدد يدوية" },
|
| 16 |
+
{ "id": 18, "nameAr": "شفاط زجاج", "nameEn": "Glass Suction", "parentName": "عدد يدوية" },
|
| 17 |
+
{ "id": 19, "nameAr": "دريل", "nameEn": "Drill", "parentName": "عدد كهربائيه" },
|
| 18 |
+
{ "id": 20, "nameAr": "عروض", "nameEn": "Offers", "parentName": null },
|
| 19 |
+
{ "id": 21, "nameAr": "ماكينة لحام", "nameEn": "Welding Machine", "parentName": "عدد لحام" },
|
| 20 |
+
{ "id": 22, "nameAr": "هزار سيراميك", "nameEn": "Ceramic Cutter", "parentName": "عدد يدوية" },
|
| 21 |
+
{ "id": 23, "nameAr": "مشحمة", "nameEn": "Grease Gun", "parentName": "عدد هواء" },
|
| 22 |
+
{ "id": 24, "nameAr": "مسدس هواء", "nameEn": "Air Gun", "parentName": "عدد هواء" },
|
| 23 |
+
{ "id": 25, "nameAr": "ماكينات هواء", "nameEn": "Air Machines", "parentName": "عدد هواء" },
|
| 24 |
+
{ "id": 26, "nameAr": "كمبروسر", "nameEn": "Air Compressor", "parentName": "عدد هواء" },
|
| 25 |
+
{ "id": 27, "nameAr": "طلمبة غسيل", "nameEn": "Washing Pump", "parentName": "عدد هواء" },
|
| 26 |
+
{ "id": 28, "nameAr": "دباسه", "nameEn": "Stapler", "parentName": "عدد هواء" },
|
| 27 |
+
{ "id": 29, "nameAr": "موازين ليزر", "nameEn": "Laser Levels", "parentName": "ادوات قياس" },
|
| 28 |
+
{ "id": 31, "nameAr": "لمبه لحام", "nameEn": "Welding Lamp", "parentName": "عدد لحام" },
|
| 29 |
+
{ "id": 32, "nameAr": "بنس و بك لحام", "nameEn": "Welding Pliers", "parentName": "عدد لحام" },
|
| 30 |
+
{ "id": 33, "nameAr": "لحام مواسير", "nameEn": "Tube Welding", "parentName": "عدد لحام" },
|
| 31 |
+
{ "id": 34, "nameAr": "منظم لحام", "nameEn": "Welding Regulator", "parentName": "عدد لحام" },
|
| 32 |
+
{ "id": 35, "nameAr": "وش لحام", "nameEn": "Welding Mask", "parentName": "عدد لحام" },
|
| 33 |
+
{ "id": 37, "nameAr": "عروض ادوات البيت", "nameEn": "Home Offers", "parentName": "عروض" },
|
| 34 |
+
{ "id": 38, "nameAr": "عروض عدد السيارات", "nameEn": "Car Tools Offers", "parentName": "عروض" },
|
| 35 |
+
{ "id": 39, "nameAr": "مشتركات", "nameEn": "Power Strips", "parentName": "عدد كهربائيه" },
|
| 36 |
+
{ "id": 40, "nameAr": "ميزان مياه", "nameEn": "Spirit Level", "parentName": "ادوات قياس" },
|
| 37 |
+
{ "id": 41, "nameAr": "روتر", "nameEn": "Router", "parentName": "عدد كهربائيه" },
|
| 38 |
+
{ "id": 42, "nameAr": "ادوات ربط", "nameEn": "Fastening Tools", "parentName": "عدد يدوية" }
|
| 39 |
+
]
|
devData/dump-broken.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const mongoose = require('mongoose');
|
| 2 |
+
const fs = require('fs');
|
| 3 |
+
const dotenv = require('dotenv');
|
| 4 |
+
const Product = require('../models/productModel');
|
| 5 |
+
|
| 6 |
+
dotenv.config({ path: './config.env' });
|
| 7 |
+
const DB = process.env.DATABASE.replace('<PASSWORD>', process.env.DATABASE_PASSWORD);
|
| 8 |
+
|
| 9 |
+
mongoose.connect(DB).then(async () => {
|
| 10 |
+
const p = await Product.findOne({ nameAr: { $regex: /صاروخ توتال 9 بوصة/ } });
|
| 11 |
+
if (p) {
|
| 12 |
+
fs.writeFileSync('broken-url.txt', p.imageCover);
|
| 13 |
+
console.log("Broken URL saved to broken-url.txt");
|
| 14 |
+
} else {
|
| 15 |
+
console.log("Product not found");
|
| 16 |
+
}
|
| 17 |
+
process.exit();
|
| 18 |
+
});
|
devData/fix-provider-ids.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const mongoose = require('mongoose');
|
| 2 |
+
const dotenv = require('dotenv');
|
| 3 |
+
const Provider = require('../models/providerModel');
|
| 4 |
+
|
| 5 |
+
dotenv.config({ path: './config.env' });
|
| 6 |
+
const DB = process.env.DATABASE.replace('<PASSWORD>', process.env.DATABASE_PASSWORD);
|
| 7 |
+
|
| 8 |
+
mongoose.connect(DB).then(async () => {
|
| 9 |
+
console.log('DB connection successful!\n');
|
| 10 |
+
|
| 11 |
+
try {
|
| 12 |
+
// 1. Get the current providers
|
| 13 |
+
const center = await Provider.findOne({ storeName: 'سنتر المنصورة' });
|
| 14 |
+
const wekalt = await Provider.findOne({ storeName: 'وكالة العدد' });
|
| 15 |
+
|
| 16 |
+
if (!center) { console.log('Could not find Center El Mansoura'); return process.exit(1); }
|
| 17 |
+
if (!wekalt) { console.log('Could not find Wekalt El 3edad'); return process.exit(1); }
|
| 18 |
+
|
| 19 |
+
console.log('Found providers. Shifting IDs to temporary numbers...');
|
| 20 |
+
|
| 21 |
+
// Assign temp IDs first to avoid unique constraint errors
|
| 22 |
+
await Provider.findByIdAndUpdate(center._id, { id: 1002 }, { validateBeforeSave: false });
|
| 23 |
+
await Provider.findByIdAndUpdate(wekalt._id, { id: 1003 }, { validateBeforeSave: false });
|
| 24 |
+
|
| 25 |
+
// 2. Check if Samoulla exists, if not create it
|
| 26 |
+
let samoulla = await Provider.findOne({ storeName: { $regex: /samoulla|صمولة/i } });
|
| 27 |
+
if (!samoulla) {
|
| 28 |
+
console.log('Creating Samoulla provider...');
|
| 29 |
+
samoulla = await Provider.create({
|
| 30 |
+
name: 'Samoulla Admin',
|
| 31 |
+
storeName: 'Samoulla',
|
| 32 |
+
address: 'Main HQ',
|
| 33 |
+
phoneNumber: '01000000000',
|
| 34 |
+
id: 1, // Give Samoulla ID 1
|
| 35 |
+
isActive: true
|
| 36 |
+
});
|
| 37 |
+
} else {
|
| 38 |
+
console.log('Samoulla already exists, updating ID to 1...');
|
| 39 |
+
await Provider.findByIdAndUpdate(samoulla._id, { id: 1 }, { validateBeforeSave: false });
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
// 3. Assign final IDs
|
| 43 |
+
console.log('Assigning final IDs to Center El Mansoura and Wekalt El 3edad...');
|
| 44 |
+
await Provider.findByIdAndUpdate(center._id, { id: 2 }, { validateBeforeSave: false });
|
| 45 |
+
await Provider.findByIdAndUpdate(wekalt._id, { id: 3 }, { validateBeforeSave: false });
|
| 46 |
+
|
| 47 |
+
console.log('\n--- Final Providers ---');
|
| 48 |
+
const all = await Provider.find({}, { storeName: 1, id: 1 }).sort({ id: 1 });
|
| 49 |
+
all.forEach(p => console.log(`ID: ${p.id} | Store: ${p.storeName}`));
|
| 50 |
+
|
| 51 |
+
console.log('\n✅ Successfully updated! No products were harmed or shifted.');
|
| 52 |
+
|
| 53 |
+
} catch (err) {
|
| 54 |
+
console.error('Error during update:', err);
|
| 55 |
+
}
|
| 56 |
+
process.exit();
|
| 57 |
+
}).catch((err) => { console.error(err); process.exit(1); });
|
devData/import-brands.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const fs = require('fs');
|
| 2 |
+
const mongoose = require('mongoose');
|
| 3 |
+
const dotenv = require('dotenv');
|
| 4 |
+
const Brand = require('../models/brandModel');
|
| 5 |
+
|
| 6 |
+
dotenv.config({ path: './config.env' });
|
| 7 |
+
|
| 8 |
+
const DB = process.env.DATABASE.replace(
|
| 9 |
+
'<PASSWORD>',
|
| 10 |
+
process.env.DATABASE_PASSWORD,
|
| 11 |
+
);
|
| 12 |
+
|
| 13 |
+
mongoose
|
| 14 |
+
.connect(DB)
|
| 15 |
+
.then(() => console.log('DB connection successful!'))
|
| 16 |
+
.catch((err) => {
|
| 17 |
+
console.log('DB connection error:', err);
|
| 18 |
+
});
|
| 19 |
+
|
| 20 |
+
// Read JSON file
|
| 21 |
+
const brands = JSON.parse(
|
| 22 |
+
fs.readFileSync(`${__dirname}/brands.json`, 'utf-8'),
|
| 23 |
+
);
|
| 24 |
+
|
| 25 |
+
// Import data into DB
|
| 26 |
+
const importData = async () => {
|
| 27 |
+
try {
|
| 28 |
+
// Optional: Delete existing brands if you want a clean seed each time
|
| 29 |
+
await Brand.deleteMany();
|
| 30 |
+
console.log('Existing brands deleted.');
|
| 31 |
+
|
| 32 |
+
await Brand.create(brands);
|
| 33 |
+
console.log('Brands successfully loaded!');
|
| 34 |
+
} catch (err) {
|
| 35 |
+
console.log(err);
|
| 36 |
+
}
|
| 37 |
+
process.exit();
|
| 38 |
+
};
|
| 39 |
+
|
| 40 |
+
const deleteData = async () => {
|
| 41 |
+
try {
|
| 42 |
+
await Brand.deleteMany();
|
| 43 |
+
console.log('Brands successfully deleted!');
|
| 44 |
+
} catch (err) {
|
| 45 |
+
console.log(err);
|
| 46 |
+
}
|
| 47 |
+
process.exit();
|
| 48 |
+
};
|
| 49 |
+
|
| 50 |
+
if (process.argv[2] === '--import') {
|
| 51 |
+
importData();
|
| 52 |
+
} else if (process.argv[2] === '--delete') {
|
| 53 |
+
deleteData();
|
| 54 |
+
}
|
devData/import-categories.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const fs = require('fs');
|
| 2 |
+
const mongoose = require('mongoose');
|
| 3 |
+
const dotenv = require('dotenv');
|
| 4 |
+
const Category = require('../models/categoryModel');
|
| 5 |
+
|
| 6 |
+
dotenv.config({ path: './config.env' });
|
| 7 |
+
|
| 8 |
+
const DB = process.env.DATABASE.replace(
|
| 9 |
+
'<PASSWORD>',
|
| 10 |
+
process.env.DATABASE_PASSWORD,
|
| 11 |
+
);
|
| 12 |
+
|
| 13 |
+
mongoose
|
| 14 |
+
.connect(DB)
|
| 15 |
+
.then(() => console.log('DB connection successful!'))
|
| 16 |
+
.catch((err) => {
|
| 17 |
+
console.log('DB connection error:', err);
|
| 18 |
+
});
|
| 19 |
+
|
| 20 |
+
// Read JSON file
|
| 21 |
+
const categories = JSON.parse(
|
| 22 |
+
fs.readFileSync(`${__dirname}/categories.json`, 'utf-8'),
|
| 23 |
+
);
|
| 24 |
+
|
| 25 |
+
// Import data into DB
|
| 26 |
+
const importData = async () => {
|
| 27 |
+
try {
|
| 28 |
+
// Delete all existing categories to avoid duplicates during seed
|
| 29 |
+
await Category.deleteMany();
|
| 30 |
+
console.log('Existing categories deleted.');
|
| 31 |
+
|
| 32 |
+
// 1. Separate root and subcategories
|
| 33 |
+
const rootCategories = categories.filter(cat => !cat.parentName);
|
| 34 |
+
const subCategories = categories.filter(cat => cat.parentName);
|
| 35 |
+
|
| 36 |
+
// 2. Insert root categories
|
| 37 |
+
const createdRoots = await Category.insertMany(rootCategories.map(cat => ({
|
| 38 |
+
id: cat.id,
|
| 39 |
+
nameAr: cat.nameAr,
|
| 40 |
+
nameEn: cat.nameEn,
|
| 41 |
+
parent: null
|
| 42 |
+
})));
|
| 43 |
+
console.log(`${createdRoots.length} Root categories loaded.`);
|
| 44 |
+
|
| 45 |
+
// 3. Create a map of nameAr to _id
|
| 46 |
+
const idMap = {};
|
| 47 |
+
const allCategories = await Category.find();
|
| 48 |
+
allCategories.forEach(cat => {
|
| 49 |
+
idMap[cat.nameAr] = cat._id;
|
| 50 |
+
});
|
| 51 |
+
|
| 52 |
+
// 4. Insert subcategories with parent _id
|
| 53 |
+
const subCatsToInsert = subCategories.map(cat => ({
|
| 54 |
+
id: cat.id,
|
| 55 |
+
nameAr: cat.nameAr,
|
| 56 |
+
nameEn: cat.nameEn,
|
| 57 |
+
parent: idMap[cat.parentName] || null
|
| 58 |
+
}));
|
| 59 |
+
|
| 60 |
+
const createdSubs = await Category.insertMany(subCatsToInsert);
|
| 61 |
+
console.log(`${createdSubs.length} Sub-categories loaded.`);
|
| 62 |
+
|
| 63 |
+
console.log('All categories successfully loaded!');
|
| 64 |
+
} catch (err) {
|
| 65 |
+
console.log(err);
|
| 66 |
+
}
|
| 67 |
+
process.exit();
|
| 68 |
+
};
|
| 69 |
+
|
| 70 |
+
const deleteData = async () => {
|
| 71 |
+
try {
|
| 72 |
+
await Category.deleteMany();
|
| 73 |
+
console.log('Categories successfully deleted!');
|
| 74 |
+
} catch (err) {
|
| 75 |
+
console.log(err);
|
| 76 |
+
}
|
| 77 |
+
process.exit();
|
| 78 |
+
};
|
| 79 |
+
|
| 80 |
+
if (process.argv[2] === '--import') {
|
| 81 |
+
importData();
|
| 82 |
+
} else if (process.argv[2] === '--delete') {
|
| 83 |
+
deleteData();
|
| 84 |
+
}
|
devData/importProducts.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const fs = require('fs');
|
| 2 |
+
const mongoose = require('mongoose');
|
| 3 |
+
const dotenv = require('dotenv');
|
| 4 |
+
const Product = require('../models/productModel');
|
| 5 |
+
const Category = require('../models/categoryModel');
|
| 6 |
+
const Provider = require('../models/providerModel');
|
| 7 |
+
|
| 8 |
+
dotenv.config({ path: './config.env' });
|
| 9 |
+
|
| 10 |
+
const DB = process.env.DATABASE.replace(
|
| 11 |
+
'<PASSWORD>',
|
| 12 |
+
process.env.DATABASE_PASSWORD,
|
| 13 |
+
);
|
| 14 |
+
|
| 15 |
+
mongoose
|
| 16 |
+
.connect(DB)
|
| 17 |
+
.then(() => console.log('DB connection successful!'))
|
| 18 |
+
.catch((err) => {
|
| 19 |
+
console.log('DB connection error:', err);
|
| 20 |
+
});
|
| 21 |
+
|
| 22 |
+
// Read JSON file
|
| 23 |
+
const products = JSON.parse(
|
| 24 |
+
fs.readFileSync(`${__dirname}/products-sample.json`, 'utf-8'),
|
| 25 |
+
);
|
| 26 |
+
|
| 27 |
+
// Import data into DB
|
| 28 |
+
const importData = async () => {
|
| 29 |
+
try {
|
| 30 |
+
await Product.create(products);
|
| 31 |
+
console.log('Data successfully loaded!');
|
| 32 |
+
} catch (err) {
|
| 33 |
+
console.log(err);
|
| 34 |
+
}
|
| 35 |
+
process.exit();
|
| 36 |
+
};
|
| 37 |
+
|
| 38 |
+
// Delete all data from DB
|
| 39 |
+
const deleteData = async () => {
|
| 40 |
+
try {
|
| 41 |
+
await Product.deleteMany();
|
| 42 |
+
await Category.deleteMany();
|
| 43 |
+
await Provider.deleteMany();
|
| 44 |
+
console.log('Data successfully deleted!');
|
| 45 |
+
} catch (err) {
|
| 46 |
+
console.log(err);
|
| 47 |
+
}
|
| 48 |
+
process.exit();
|
| 49 |
+
};
|
| 50 |
+
|
| 51 |
+
if (process.argv[2] === '--import') {
|
| 52 |
+
importData();
|
| 53 |
+
} else if (process.argv[2] === '--delete') {
|
| 54 |
+
deleteData();
|
| 55 |
+
}
|
devData/inspect-broken.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const mongoose = require('mongoose');
|
| 2 |
+
const dotenv = require('dotenv');
|
| 3 |
+
const Product = require('../models/productModel');
|
| 4 |
+
|
| 5 |
+
dotenv.config({ path: './config.env' });
|
| 6 |
+
const DB = process.env.DATABASE.replace('<PASSWORD>', process.env.DATABASE_PASSWORD);
|
| 7 |
+
|
| 8 |
+
mongoose.connect(DB).then(async () => {
|
| 9 |
+
const p = await Product.findOne({ nameAr: { $regex: /صاروخ توتال 9 بوصة/ } });
|
| 10 |
+
if (p) {
|
| 11 |
+
console.log("Product Name:", p.nameAr);
|
| 12 |
+
console.log("imageCover:", p.imageCover);
|
| 13 |
+
} else {
|
| 14 |
+
console.log("Product not found");
|
| 15 |
+
}
|
| 16 |
+
process.exit();
|
| 17 |
+
});
|
devData/list-providers.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const mongoose = require('mongoose');
|
| 2 |
+
const dotenv = require('dotenv');
|
| 3 |
+
const fs = require('fs');
|
| 4 |
+
const Provider = require('../models/providerModel');
|
| 5 |
+
|
| 6 |
+
dotenv.config({ path: './config.env' });
|
| 7 |
+
|
| 8 |
+
const DB = process.env.DATABASE.replace('<PASSWORD>', process.env.DATABASE_PASSWORD);
|
| 9 |
+
|
| 10 |
+
mongoose.connect(DB).then(async () => {
|
| 11 |
+
const all = await Provider.find({}, { storeName: 1, name: 1, id: 1 }).sort({ id: 1 }).lean();
|
| 12 |
+
fs.writeFileSync(__dirname + '/providers-dump.json', JSON.stringify(all, null, 2), 'utf-8');
|
| 13 |
+
console.log('Written to providers-dump.json');
|
| 14 |
+
process.exit();
|
| 15 |
+
}).catch((err) => { console.error(err); process.exit(1); });
|
devData/products-sample.json
ADDED
|
@@ -0,0 +1,448 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"nameEn": "Cordless Drill 18V",
|
| 4 |
+
"nameAr": "مثقاب لاسلكي 18 فولت",
|
| 5 |
+
"descriptionEn": "High power cordless drill with two lithium batteries and fast charger.",
|
| 6 |
+
"descriptionAr": "مثقاب لاسلكي عالي القوة مع بطاريتين ليثيوم وشاحن سريع.",
|
| 7 |
+
"barCode": "100000000001",
|
| 8 |
+
"brand": "Bosch",
|
| 9 |
+
"category": "6908d121e0f022413d414ff9",
|
| 10 |
+
"provider": "Cairo Tools",
|
| 11 |
+
"available": true,
|
| 12 |
+
"quantity": 40,
|
| 13 |
+
"price": 1499,
|
| 14 |
+
"purchasePrice": 1100,
|
| 15 |
+
"salePrice": 1399,
|
| 16 |
+
"imageCover": "https://example.com/images/drill1.jpg",
|
| 17 |
+
"images": [
|
| 18 |
+
"https://example.com/images/drill1-1.jpg",
|
| 19 |
+
"https://example.com/images/drill1-2.jpg"
|
| 20 |
+
],
|
| 21 |
+
"ratingsAverage": 4.6,
|
| 22 |
+
"ratingsQuantity": 18,
|
| 23 |
+
"addedAt": "2025-10-21T10:00:00Z"
|
| 24 |
+
},
|
| 25 |
+
{
|
| 26 |
+
"nameEn": "Impact Drill 750W",
|
| 27 |
+
"nameAr": "مثقاب صدمات 750 واط",
|
| 28 |
+
"descriptionEn": "Heavy-duty impact drill suitable for concrete and steel.",
|
| 29 |
+
"descriptionAr": "مثقاب قوي مناسب للخرسانة والفولاذ.",
|
| 30 |
+
"barCode": "100000000002",
|
| 31 |
+
"brand": "Makita",
|
| 32 |
+
"category": "6908d121e0f022413d414ff9",
|
| 33 |
+
"provider": "Alex Hardware",
|
| 34 |
+
"available": true,
|
| 35 |
+
"quantity": 25,
|
| 36 |
+
"price": 1799,
|
| 37 |
+
"purchasePrice": 1350,
|
| 38 |
+
"salePrice": 1699,
|
| 39 |
+
"imageCover": "https://example.com/images/impactdrill.jpg",
|
| 40 |
+
"images": [
|
| 41 |
+
"https://example.com/images/impactdrill-1.jpg"
|
| 42 |
+
],
|
| 43 |
+
"ratingsAverage": 4.7,
|
| 44 |
+
"ratingsQuantity": 22,
|
| 45 |
+
"addedAt": "2025-10-21T10:00:00Z"
|
| 46 |
+
},
|
| 47 |
+
{
|
| 48 |
+
"nameEn": "Electric Screwdriver 12V",
|
| 49 |
+
"nameAr": "مفك كهربائي 12 فولت",
|
| 50 |
+
"descriptionEn": "Compact 12V electric screwdriver with adjustable torque.",
|
| 51 |
+
"descriptionAr": "مفك كهربائي مدمج بجهد 12 فولت مع عزم قابل للتعديل.",
|
| 52 |
+
"barCode": "100000000003",
|
| 53 |
+
"brand": "DeWalt",
|
| 54 |
+
"category": "6908d121e0f022413d414ff9",
|
| 55 |
+
"provider": "Delta Tools",
|
| 56 |
+
"available": true,
|
| 57 |
+
"quantity": 60,
|
| 58 |
+
"price": 899,
|
| 59 |
+
"purchasePrice": 650,
|
| 60 |
+
"salePrice": 850,
|
| 61 |
+
"imageCover": "https://example.com/images/screwdriver1.jpg",
|
| 62 |
+
"images": [
|
| 63 |
+
"https://example.com/images/screwdriver1-1.jpg"
|
| 64 |
+
],
|
| 65 |
+
"ratingsAverage": 4.4,
|
| 66 |
+
"ratingsQuantity": 11,
|
| 67 |
+
"addedAt": "2025-10-21T10:00:00Z"
|
| 68 |
+
},
|
| 69 |
+
{
|
| 70 |
+
"nameEn": "Angle Grinder 1000W",
|
| 71 |
+
"nameAr": "صاروخ جلخ 1000 واط",
|
| 72 |
+
"descriptionEn": "High-speed angle grinder ideal for metal cutting and polishing.",
|
| 73 |
+
"descriptionAr": "صاروخ جلخ عالي السرعة مثالي لقطع المعادن والتلميع.",
|
| 74 |
+
"barCode": "100000000004",
|
| 75 |
+
"brand": "Black+Decker",
|
| 76 |
+
"category": "6908d121e0f022413d414ff9",
|
| 77 |
+
"provider": "PowerPro Supplies",
|
| 78 |
+
"available": true,
|
| 79 |
+
"quantity": 35,
|
| 80 |
+
"price": 1199,
|
| 81 |
+
"purchasePrice": 880,
|
| 82 |
+
"salePrice": 1099,
|
| 83 |
+
"imageCover": "https://example.com/images/grinder.jpg",
|
| 84 |
+
"images": [
|
| 85 |
+
"https://example.com/images/grinder-1.jpg"
|
| 86 |
+
],
|
| 87 |
+
"ratingsAverage": 4.5,
|
| 88 |
+
"ratingsQuantity": 14,
|
| 89 |
+
"addedAt": "2025-10-21T10:00:00Z"
|
| 90 |
+
},
|
| 91 |
+
{
|
| 92 |
+
"nameEn": "Circular Saw 1400W",
|
| 93 |
+
"nameAr": "منشار دائري 1400 واط",
|
| 94 |
+
"descriptionEn": "Powerful circular saw for wood and plastic materials.",
|
| 95 |
+
"descriptionAr": "منشار دائري قوي للخشب والمواد البلاستيكية.",
|
| 96 |
+
"barCode": "100000000005",
|
| 97 |
+
"brand": "Makita",
|
| 98 |
+
"category": "6908d121e0f022413d414ff9",
|
| 99 |
+
"provider": "Delta Tools",
|
| 100 |
+
"available": true,
|
| 101 |
+
"quantity": 20,
|
| 102 |
+
"price": 2299,
|
| 103 |
+
"purchasePrice": 1800,
|
| 104 |
+
"salePrice": 2199,
|
| 105 |
+
"imageCover": "https://example.com/images/saw1.jpg",
|
| 106 |
+
"images": [
|
| 107 |
+
"https://example.com/images/saw1-1.jpg"
|
| 108 |
+
],
|
| 109 |
+
"ratingsAverage": 4.8,
|
| 110 |
+
"ratingsQuantity": 16,
|
| 111 |
+
"addedAt": "2025-10-21T10:00:00Z"
|
| 112 |
+
},
|
| 113 |
+
{
|
| 114 |
+
"nameEn": "Hammer Drill 900W",
|
| 115 |
+
"nameAr": "مثقاب مطرقي 900 واط",
|
| 116 |
+
"descriptionEn": "Powerful hammer drill with dual speed and metal case.",
|
| 117 |
+
"descriptionAr": "مثقاب مطرقي قوي بسرعتين وهيكل معدني.",
|
| 118 |
+
"barCode": "100000000006",
|
| 119 |
+
"brand": "Bosch",
|
| 120 |
+
"category": "6908d121e0f022413d414ff9",
|
| 121 |
+
"provider": "Cairo Tools",
|
| 122 |
+
"available": true,
|
| 123 |
+
"quantity": 18,
|
| 124 |
+
"price": 1999,
|
| 125 |
+
"purchasePrice": 1550,
|
| 126 |
+
"salePrice": 1899,
|
| 127 |
+
"imageCover": "https://example.com/images/hammerdrill.jpg",
|
| 128 |
+
"images": [
|
| 129 |
+
"https://example.com/images/hammerdrill-1.jpg"
|
| 130 |
+
],
|
| 131 |
+
"ratingsAverage": 4.7,
|
| 132 |
+
"ratingsQuantity": 20,
|
| 133 |
+
"addedAt": "2025-10-21T10:00:00Z"
|
| 134 |
+
},
|
| 135 |
+
{
|
| 136 |
+
"nameEn": "Bench Grinder 150mm",
|
| 137 |
+
"nameAr": "صاروخ طاولة 150مم",
|
| 138 |
+
"descriptionEn": "Durable bench grinder for sharpening and shaping tools.",
|
| 139 |
+
"descriptionAr": "صاروخ طاولة متين لشحذ وتشكيل الأدوات.",
|
| 140 |
+
"barCode": "100000000007",
|
| 141 |
+
"brand": "Stanley",
|
| 142 |
+
"category": "6908d121e0f022413d414ff9",
|
| 143 |
+
"provider": "PowerPro Supplies",
|
| 144 |
+
"available": true,
|
| 145 |
+
"quantity": 15,
|
| 146 |
+
"price": 999,
|
| 147 |
+
"purchasePrice": 750,
|
| 148 |
+
"salePrice": 950,
|
| 149 |
+
"imageCover": "https://example.com/images/benchgrinder.jpg",
|
| 150 |
+
"images": [
|
| 151 |
+
"https://example.com/images/benchgrinder-1.jpg"
|
| 152 |
+
],
|
| 153 |
+
"ratingsAverage": 4.3,
|
| 154 |
+
"ratingsQuantity": 9,
|
| 155 |
+
"addedAt": "2025-10-21T10:00:00Z"
|
| 156 |
+
},
|
| 157 |
+
{
|
| 158 |
+
"nameEn": "Heat Gun 2000W",
|
| 159 |
+
"nameAr": "مسدس حراري 2000 واط",
|
| 160 |
+
"descriptionEn": "Adjustable temperature heat gun for paint stripping and welding.",
|
| 161 |
+
"descriptionAr": "مسدس حراري بدرجة حرارة قابلة للتعديل لإزالة الطلاء واللحام.",
|
| 162 |
+
"barCode": "100000000008",
|
| 163 |
+
"brand": "Bosch",
|
| 164 |
+
"category": "6908d121e0f022413d414ff9",
|
| 165 |
+
"provider": "ToolMaster",
|
| 166 |
+
"available": true,
|
| 167 |
+
"quantity": 30,
|
| 168 |
+
"price": 899,
|
| 169 |
+
"purchasePrice": 650,
|
| 170 |
+
"salePrice": 850,
|
| 171 |
+
"imageCover": "https://example.com/images/heatgun.jpg",
|
| 172 |
+
"images": [
|
| 173 |
+
"https://example.com/images/heatgun-1.jpg"
|
| 174 |
+
],
|
| 175 |
+
"ratingsAverage": 4.4,
|
| 176 |
+
"ratingsQuantity": 13,
|
| 177 |
+
"addedAt": "2025-10-21T10:00:00Z"
|
| 178 |
+
},
|
| 179 |
+
{
|
| 180 |
+
"nameEn": "Electric Planer 600W",
|
| 181 |
+
"nameAr": "مِسحَج كهربائي 600 واط",
|
| 182 |
+
"descriptionEn": "Smooth surface planer for carpentry and woodworking.",
|
| 183 |
+
"descriptionAr": "مِسحَج كهربائي لنعومة الأسطح في أعمال النجارة.",
|
| 184 |
+
"barCode": "100000000009",
|
| 185 |
+
"brand": "Makita",
|
| 186 |
+
"category": "6908d121e0f022413d414ff9",
|
| 187 |
+
"provider": "Alex Hardware",
|
| 188 |
+
"available": true,
|
| 189 |
+
"quantity": 25,
|
| 190 |
+
"price": 1299,
|
| 191 |
+
"purchasePrice": 950,
|
| 192 |
+
"salePrice": 1199,
|
| 193 |
+
"imageCover": "https://example.com/images/planer.jpg",
|
| 194 |
+
"images": [
|
| 195 |
+
"https://example.com/images/planer-1.jpg"
|
| 196 |
+
],
|
| 197 |
+
"ratingsAverage": 4.5,
|
| 198 |
+
"ratingsQuantity": 15,
|
| 199 |
+
"addedAt": "2025-10-21T10:00:00Z"
|
| 200 |
+
},
|
| 201 |
+
{
|
| 202 |
+
"nameEn": "Air Compressor 24L",
|
| 203 |
+
"nameAr": "كمبروسر هواء 24 لتر",
|
| 204 |
+
"descriptionEn": "Portable air compressor with 24-liter tank and pressure gauge.",
|
| 205 |
+
"descriptionAr": "كمبروسر هواء محمول بخزان سعة 24 لتر ومقياس ضغط.",
|
| 206 |
+
"barCode": "100000000010",
|
| 207 |
+
"brand": "Stanley",
|
| 208 |
+
"category": "6908d121e0f022413d414ff9",
|
| 209 |
+
"provider": "Cairo Tools",
|
| 210 |
+
"available": true,
|
| 211 |
+
"quantity": 10,
|
| 212 |
+
"price": 2999,
|
| 213 |
+
"purchasePrice": 2500,
|
| 214 |
+
"salePrice": 2899,
|
| 215 |
+
"imageCover": "https://example.com/images/compressor.jpg",
|
| 216 |
+
"images": [
|
| 217 |
+
"https://example.com/images/compressor-1.jpg"
|
| 218 |
+
],
|
| 219 |
+
"ratingsAverage": 4.8,
|
| 220 |
+
"ratingsQuantity": 27,
|
| 221 |
+
"addedAt": "2025-10-21T10:00:00Z"
|
| 222 |
+
},
|
| 223 |
+
{
|
| 224 |
+
"nameEn": "Cordless Screwdriver Set",
|
| 225 |
+
"nameAr": "طقم مفكات لاسلكية",
|
| 226 |
+
"descriptionEn": "Rechargeable screwdriver set with 30 bits and LED light.",
|
| 227 |
+
"descriptionAr": "طقم مفكات لاسلكية قابلة للشحن مع 30 رأس و ضوء LED.",
|
| 228 |
+
"barCode": "100000000011",
|
| 229 |
+
"brand": "Black+Decker",
|
| 230 |
+
"category": "6908d121e0f022413d414ff9",
|
| 231 |
+
"provider": "Delta Tools",
|
| 232 |
+
"available": true,
|
| 233 |
+
"quantity": 50,
|
| 234 |
+
"price": 699,
|
| 235 |
+
"purchasePrice": 480,
|
| 236 |
+
"salePrice": 650,
|
| 237 |
+
"imageCover": "https://example.com/images/screwset.jpg",
|
| 238 |
+
"images": [
|
| 239 |
+
"https://example.com/images/screwset-1.jpg"
|
| 240 |
+
],
|
| 241 |
+
"ratingsAverage": 4.6,
|
| 242 |
+
"ratingsQuantity": 10,
|
| 243 |
+
"addedAt": "2025-10-21T10:00:00Z"
|
| 244 |
+
},
|
| 245 |
+
{
|
| 246 |
+
"nameEn": "Mini Grinder 500W",
|
| 247 |
+
"nameAr": "صاروخ صغير 500 واط",
|
| 248 |
+
"descriptionEn": "Lightweight mini grinder for detailed cutting and polishing work.",
|
| 249 |
+
"descriptionAr": "صاروخ صغير خفيف لعمليات القطع والتلميع الدقيقة.",
|
| 250 |
+
"barCode": "100000000012",
|
| 251 |
+
"brand": "Bosch",
|
| 252 |
+
"category": "6908d121e0f022413d414ff9",
|
| 253 |
+
"provider": "ToolMaster",
|
| 254 |
+
"available": true,
|
| 255 |
+
"quantity": 28,
|
| 256 |
+
"price": 799,
|
| 257 |
+
"purchasePrice": 600,
|
| 258 |
+
"salePrice": 750,
|
| 259 |
+
"imageCover": "https://example.com/images/minigrinder.jpg",
|
| 260 |
+
"images": [
|
| 261 |
+
"https://example.com/images/minigrinder-1.jpg"
|
| 262 |
+
],
|
| 263 |
+
"ratingsAverage": 4.5,
|
| 264 |
+
"ratingsQuantity": 8,
|
| 265 |
+
"addedAt": "2025-10-21T10:00:00Z"
|
| 266 |
+
},
|
| 267 |
+
{
|
| 268 |
+
"nameEn": "Jigsaw 650W",
|
| 269 |
+
"nameAr": "منشار ترددي 650 واط",
|
| 270 |
+
"descriptionEn": "Variable-speed jigsaw ideal for curved and straight cuts.",
|
| 271 |
+
"descriptionAr": "منشار ترددي بسرعة متغيرة مثالي للقطع المستقيم والمنحني.",
|
| 272 |
+
"barCode": "100000000013",
|
| 273 |
+
"brand": "DeWalt",
|
| 274 |
+
"category": "6908d121e0f022413d414ff9",
|
| 275 |
+
"provider": "Alex Hardware",
|
| 276 |
+
"available": true,
|
| 277 |
+
"quantity": 17,
|
| 278 |
+
"price": 1599,
|
| 279 |
+
"purchasePrice": 1200,
|
| 280 |
+
"salePrice": 1499,
|
| 281 |
+
"imageCover": "https://example.com/images/jigsaw.jpg",
|
| 282 |
+
"images": [
|
| 283 |
+
"https://example.com/images/jigsaw-1.jpg"
|
| 284 |
+
],
|
| 285 |
+
"ratingsAverage": 4.6,
|
| 286 |
+
"ratingsQuantity": 12,
|
| 287 |
+
"addedAt": "2025-10-21T10:00:00Z"
|
| 288 |
+
},
|
| 289 |
+
{
|
| 290 |
+
"nameEn": "Cordless Grinder 20V",
|
| 291 |
+
"nameAr": "صاروخ لاسلكي 20 فولت",
|
| 292 |
+
"descriptionEn": "Cordless grinder with brushless motor and fast charging battery.",
|
| 293 |
+
"descriptionAr": "صاروخ لاسلكي بمحرك بدون فُرش وبطارية شحن سريع.",
|
| 294 |
+
"barCode": "100000000014",
|
| 295 |
+
"brand": "Makita",
|
| 296 |
+
"category": "6908d121e0f022413d414ff9",
|
| 297 |
+
"provider": "Cairo Tools",
|
| 298 |
+
"available": true,
|
| 299 |
+
"quantity": 12,
|
| 300 |
+
"price": 2499,
|
| 301 |
+
"purchasePrice": 2000,
|
| 302 |
+
"salePrice": 2399,
|
| 303 |
+
"imageCover": "https://example.com/images/cordlessgrinder.jpg",
|
| 304 |
+
"images": [
|
| 305 |
+
"https://example.com/images/cordlessgrinder-1.jpg"
|
| 306 |
+
],
|
| 307 |
+
"ratingsAverage": 4.9,
|
| 308 |
+
"ratingsQuantity": 25,
|
| 309 |
+
"addedAt": "2025-10-21T10:00:00Z"
|
| 310 |
+
},
|
| 311 |
+
{
|
| 312 |
+
"nameEn": "Rotary Hammer 1200W",
|
| 313 |
+
"nameAr": "مثقاب مطرقي دوار 1200 واط",
|
| 314 |
+
"descriptionEn": "Professional rotary hammer for drilling through concrete and stone.",
|
| 315 |
+
"descriptionAr": "مثقاب مطرقي دوار احترافي للحفر في الخرسانة والحجر.",
|
| 316 |
+
"barCode": "100000000015",
|
| 317 |
+
"brand": "Bosch",
|
| 318 |
+
"category": "6908d121e0f022413d414ff9",
|
| 319 |
+
"provider": "ToolMaster",
|
| 320 |
+
"available": true,
|
| 321 |
+
"quantity": 9,
|
| 322 |
+
"price": 2799,
|
| 323 |
+
"purchasePrice": 2200,
|
| 324 |
+
"salePrice": 2699,
|
| 325 |
+
"imageCover": "https://example.com/images/rotaryhammer.jpg",
|
| 326 |
+
"images": [
|
| 327 |
+
"https://example.com/images/rotaryhammer-1.jpg"
|
| 328 |
+
],
|
| 329 |
+
"ratingsAverage": 4.8,
|
| 330 |
+
"ratingsQuantity": 20,
|
| 331 |
+
"addedAt": "2025-10-21T10:00:00Z"
|
| 332 |
+
},
|
| 333 |
+
{
|
| 334 |
+
"nameEn": "Electric Sander 300W",
|
| 335 |
+
"nameAr": "صنفرة كهربائية 300 واط",
|
| 336 |
+
"descriptionEn": "Orbital sander with dust collector for clean finish.",
|
| 337 |
+
"descriptionAr": "صنفرة دائرية مزودة بجامع غبار لتشطيب نظيف.",
|
| 338 |
+
"barCode": "100000000016",
|
| 339 |
+
"brand": "Stanley",
|
| 340 |
+
"category": "6908d121e0f022413d414ff9",
|
| 341 |
+
"provider": "PowerPro Supplies",
|
| 342 |
+
"available": true,
|
| 343 |
+
"quantity": 22,
|
| 344 |
+
"price": 999,
|
| 345 |
+
"purchasePrice": 700,
|
| 346 |
+
"salePrice": 950,
|
| 347 |
+
"imageCover": "https://example.com/images/sander.jpg",
|
| 348 |
+
"images": [
|
| 349 |
+
"https://example.com/images/sander-1.jpg"
|
| 350 |
+
],
|
| 351 |
+
"ratingsAverage": 4.5,
|
| 352 |
+
"ratingsQuantity": 14,
|
| 353 |
+
"addedAt": "2025-10-21T10:00:00Z",
|
| 354 |
+
"isFeatured": true
|
| 355 |
+
},
|
| 356 |
+
{
|
| 357 |
+
"nameEn": "Laser Distance Meter 40m",
|
| 358 |
+
"nameAr": "جهاز قياس مسافة ليزر 40م",
|
| 359 |
+
"descriptionEn": "Accurate laser distance meter for construction and decoration.",
|
| 360 |
+
"descriptionAr": "جهاز قياس ليزر دقيق للبناء والديكور.",
|
| 361 |
+
"barCode": "100000000017",
|
| 362 |
+
"brand": "Bosch",
|
| 363 |
+
"category": "6908d121e0f022413d414ff9",
|
| 364 |
+
"provider": "Delta Tools",
|
| 365 |
+
"available": true,
|
| 366 |
+
"quantity": 30,
|
| 367 |
+
"price": 899,
|
| 368 |
+
"purchasePrice": 650,
|
| 369 |
+
"salePrice": 850,
|
| 370 |
+
"imageCover": "https://example.com/images/laser.jpg",
|
| 371 |
+
"images": [
|
| 372 |
+
"https://example.com/images/laser-1.jpg"
|
| 373 |
+
],
|
| 374 |
+
"ratingsAverage": 4.7,
|
| 375 |
+
"ratingsQuantity": 18,
|
| 376 |
+
"addedAt": "2025-10-21T10:00:00Z",
|
| 377 |
+
"isFeatured": true
|
| 378 |
+
},
|
| 379 |
+
{
|
| 380 |
+
"nameEn": "Cordless Nail Gun 18V",
|
| 381 |
+
"nameAr": "مسدس مسامير لاسلكي 18 فولت",
|
| 382 |
+
"descriptionEn": "Battery-powered nail gun for furniture and woodwork.",
|
| 383 |
+
"descriptionAr": "مسدس مسامير يعمل بالبطارية لأعمال الأثاث والنجارة.",
|
| 384 |
+
"barCode": "100000000018",
|
| 385 |
+
"brand": "DeWalt",
|
| 386 |
+
"category": "6908d121e0f022413d414ff9",
|
| 387 |
+
"provider": "Cairo Tools",
|
| 388 |
+
"available": true,
|
| 389 |
+
"quantity": 15,
|
| 390 |
+
"price": 2599,
|
| 391 |
+
"purchasePrice": 2100,
|
| 392 |
+
"salePrice": 2499,
|
| 393 |
+
"imageCover": "https://example.com/images/nailgun.jpg",
|
| 394 |
+
"images": [
|
| 395 |
+
"https://example.com/images/nailgun-1.jpg"
|
| 396 |
+
],
|
| 397 |
+
"ratingsAverage": 4.8,
|
| 398 |
+
"ratingsQuantity": 22,
|
| 399 |
+
"addedAt": "2025-10-21T10:00:00Z",
|
| 400 |
+
"isFeatured": true
|
| 401 |
+
},
|
| 402 |
+
{
|
| 403 |
+
"nameEn": "Electric Chainsaw 1800W",
|
| 404 |
+
"nameAr": "منشار كهربائي 1800 واط",
|
| 405 |
+
"descriptionEn": "Fast-cutting electric chainsaw for garden and construction.",
|
| 406 |
+
"descriptionAr": "منشار كهربائي سريع القطع للحدائق وأعمال البناء.",
|
| 407 |
+
"barCode": "100000000019",
|
| 408 |
+
"brand": "Makita",
|
| 409 |
+
"category": "6908d121e0f022413d414ff9",
|
| 410 |
+
"provider": "PowerPro Supplies",
|
| 411 |
+
"available": true,
|
| 412 |
+
"quantity": 14,
|
| 413 |
+
"price": 2199,
|
| 414 |
+
"purchasePrice": 1800,
|
| 415 |
+
"salePrice": 2099,
|
| 416 |
+
"imageCover": "https://example.com/images/chainsaw.jpg",
|
| 417 |
+
"images": [
|
| 418 |
+
"https://example.com/images/chainsaw-1.jpg"
|
| 419 |
+
],
|
| 420 |
+
"ratingsAverage": 4.6,
|
| 421 |
+
"ratingsQuantity": 16,
|
| 422 |
+
"addedAt": "2025-10-21T10:00:00Z",
|
| 423 |
+
"isFeatured": true
|
| 424 |
+
},
|
| 425 |
+
{
|
| 426 |
+
"nameEn": "Tile Cutter 800W",
|
| 427 |
+
"nameAr": "قاطع بلاط 800 واط",
|
| 428 |
+
"descriptionEn": "Precision electric tile cutter with water cooling.",
|
| 429 |
+
"descriptionAr": "قاطع بلاط كهربائي دقيق مع تبريد مائي.",
|
| 430 |
+
"barCode": "100000000020",
|
| 431 |
+
"brand": "Bosch",
|
| 432 |
+
"category": "6908d121e0f022413d414ff9",
|
| 433 |
+
"provider": "Alex Hardware",
|
| 434 |
+
"available": true,
|
| 435 |
+
"quantity": 12,
|
| 436 |
+
"price": 1899,
|
| 437 |
+
"purchasePrice": 1500,
|
| 438 |
+
"salePrice": 1799,
|
| 439 |
+
"imageCover": "https://example.com/images/tilecutter.jpg",
|
| 440 |
+
"images": [
|
| 441 |
+
"https://example.com/images/tilecutter-1.jpg"
|
| 442 |
+
],
|
| 443 |
+
"ratingsAverage": 4.7,
|
| 444 |
+
"ratingsQuantity": 15,
|
| 445 |
+
"addedAt": "2025-10-21T10:00:00Z",
|
| 446 |
+
"isFeatured": true
|
| 447 |
+
}
|
| 448 |
+
]
|
devData/providers-dump.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"_id": "69cf17ca5c8342eaac72b15e",
|
| 4 |
+
"name": "amr tag",
|
| 5 |
+
"storeName": "سنتر المنصورة",
|
| 6 |
+
"id": 1
|
| 7 |
+
},
|
| 8 |
+
{
|
| 9 |
+
"_id": "69cf18bb5c8342eaac72b29a",
|
| 10 |
+
"name": "mohamed moawad",
|
| 11 |
+
"storeName": "وكالة العدد",
|
| 12 |
+
"id": 2
|
| 13 |
+
}
|
| 14 |
+
]
|
devData/shift-products.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const mongoose = require('mongoose');
|
| 2 |
+
const dotenv = require('dotenv');
|
| 3 |
+
const Product = require('../models/productModel');
|
| 4 |
+
const Provider = require('../models/providerModel');
|
| 5 |
+
|
| 6 |
+
dotenv.config({ path: './config.env' });
|
| 7 |
+
const DB = process.env.DATABASE.replace('<PASSWORD>', process.env.DATABASE_PASSWORD);
|
| 8 |
+
|
| 9 |
+
mongoose.connect(DB).then(async () => {
|
| 10 |
+
console.log('DB connection successful!\n');
|
| 11 |
+
|
| 12 |
+
try {
|
| 13 |
+
const samoulla = await Provider.findOne({ storeName: { $regex: /samoulla|صمولة/i } });
|
| 14 |
+
const center = await Provider.findOne({ storeName: 'سنتر المنصورة' });
|
| 15 |
+
const wekalt = await Provider.findOne({ storeName: 'وكالة العدد' });
|
| 16 |
+
|
| 17 |
+
if (!samoulla || !center || !wekalt) {
|
| 18 |
+
console.log('Could not find one of the providers. Aborting.');
|
| 19 |
+
process.exit(1);
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
console.log('--- Current Product Counts ---');
|
| 23 |
+
const centerCount = await Product.countDocuments({ provider: center._id });
|
| 24 |
+
const wekaltCount = await Product.countDocuments({ provider: wekalt._id });
|
| 25 |
+
const samoullaCount = await Product.countDocuments({ provider: samoulla._id });
|
| 26 |
+
console.log(`Samoulla: ${samoullaCount} products`);
|
| 27 |
+
console.log(`Center El Mansoura: ${centerCount} products`);
|
| 28 |
+
console.log(`Wekalt El 3edad: ${wekaltCount} products\n`);
|
| 29 |
+
|
| 30 |
+
console.log('--- Shifting Products ---');
|
| 31 |
+
|
| 32 |
+
// 1. Move Center products -> Samoulla
|
| 33 |
+
if (centerCount > 0) {
|
| 34 |
+
const result1 = await Product.updateMany(
|
| 35 |
+
{ provider: center._id },
|
| 36 |
+
{ provider: samoulla._id }
|
| 37 |
+
);
|
| 38 |
+
console.log(`Moved ${result1.modifiedCount} products from Center El Mansoura to Samoulla`);
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
// 2. Move Wekalt products -> Center
|
| 42 |
+
if (wekaltCount > 0) {
|
| 43 |
+
const result2 = await Product.updateMany(
|
| 44 |
+
{ provider: wekalt._id },
|
| 45 |
+
{ provider: center._id }
|
| 46 |
+
);
|
| 47 |
+
console.log(`Moved ${result2.modifiedCount} products from Wekalt El 3edad to Center El Mansoura`);
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
console.log('\n--- Final Product Counts ---');
|
| 51 |
+
const newCenterCount = await Product.countDocuments({ provider: center._id });
|
| 52 |
+
const newWekaltCount = await Product.countDocuments({ provider: wekalt._id });
|
| 53 |
+
const newSamoullaCount = await Product.countDocuments({ provider: samoulla._id });
|
| 54 |
+
|
| 55 |
+
console.log(`Samoulla (ID 1): ${newSamoullaCount} products`);
|
| 56 |
+
console.log(`Center El Mansoura (ID 2): ${newCenterCount} products`);
|
| 57 |
+
console.log(`Wekalt El 3edad (ID 3): ${newWekaltCount} products`);
|
| 58 |
+
|
| 59 |
+
console.log('\n✅ Products successfully shifted!');
|
| 60 |
+
} catch (err) {
|
| 61 |
+
console.error('Error:', err);
|
| 62 |
+
}
|
| 63 |
+
process.exit();
|
| 64 |
+
}).catch((err) => { console.error(err); process.exit(1); });
|
devData/test-precise.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const https = require('https');
|
| 2 |
+
|
| 3 |
+
const urls = [
|
| 4 |
+
'https://res.cloudinary.com/dm9ym99zh/image/upload/v1771515557/samoulla/products/6996da5201c93defc57509d4/cover-1771515557455.jpg',
|
| 5 |
+
'https://res.cloudinary.com/dm9ym99zh/image/upload/f_webp,q_auto,w_800,c_fill/v1771515557/samoulla/products/6996da5201c93defc57509d4/cover-1771515557455.jpg'
|
| 6 |
+
];
|
| 7 |
+
|
| 8 |
+
function check(url) {
|
| 9 |
+
return new Promise((resolve) => {
|
| 10 |
+
https.get(url, (res) => {
|
| 11 |
+
console.log(`URL: ${url}`);
|
| 12 |
+
console.log(`Status: ${res.statusCode}`);
|
| 13 |
+
resolve();
|
| 14 |
+
}).on('error', (err) => {
|
| 15 |
+
console.log(`URL: ${url}`);
|
| 16 |
+
console.log(`Error: ${err.message}`);
|
| 17 |
+
resolve();
|
| 18 |
+
});
|
| 19 |
+
});
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
async function run() {
|
| 23 |
+
for (const url of urls) {
|
| 24 |
+
await check(url);
|
| 25 |
+
}
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
run();
|
image-formats.json
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"cloudinary": 1036,
|
| 3 |
+
"other_http": 6,
|
| 4 |
+
"base64": 1
|
| 5 |
+
}
|
middlewares/audioUploadMiddleware.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const multer = require('multer');
|
| 2 |
+
|
| 3 |
+
const storage = multer.memoryStorage();
|
| 4 |
+
|
| 5 |
+
const allowedMimeTypes = new Set([
|
| 6 |
+
'audio/webm',
|
| 7 |
+
'audio/wav',
|
| 8 |
+
'audio/mpeg',
|
| 9 |
+
'audio/mp4',
|
| 10 |
+
'audio/x-m4a',
|
| 11 |
+
'audio/ogg',
|
| 12 |
+
]);
|
| 13 |
+
|
| 14 |
+
const fileFilter = (req, file, cb) => {
|
| 15 |
+
const mimeType = (file && file.mimetype) || '';
|
| 16 |
+
const isKnownAudioType = allowedMimeTypes.has(mimeType);
|
| 17 |
+
const isAnyAudioSubtype =
|
| 18 |
+
typeof mimeType === 'string' && mimeType.startsWith('audio/');
|
| 19 |
+
|
| 20 |
+
if (isKnownAudioType || isAnyAudioSubtype) {
|
| 21 |
+
cb(null, true);
|
| 22 |
+
} else {
|
| 23 |
+
cb(
|
| 24 |
+
new Error('Only audio files are allowed for voice transcription.'),
|
| 25 |
+
false,
|
| 26 |
+
);
|
| 27 |
+
}
|
| 28 |
+
};
|
| 29 |
+
|
| 30 |
+
const audioUpload = multer({
|
| 31 |
+
storage,
|
| 32 |
+
fileFilter,
|
| 33 |
+
limits: {
|
| 34 |
+
fileSize: 10 * 1024 * 1024,
|
| 35 |
+
},
|
| 36 |
+
});
|
| 37 |
+
|
| 38 |
+
module.exports = audioUpload;
|
middlewares/authMiddleware.js
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const User = require('../models/userModel');
|
| 2 |
+
|
| 3 |
+
// Middleware to restrict access to specific roles
|
| 4 |
+
exports.restrictTo = (...roles) => {
|
| 5 |
+
return (req, res, next) => {
|
| 6 |
+
// req.user is set by the protect middleware
|
| 7 |
+
if (!req.user) {
|
| 8 |
+
return res.status(401).json({
|
| 9 |
+
status: 'fail',
|
| 10 |
+
message: 'You must be logged in to access this resource',
|
| 11 |
+
});
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
if (!roles.includes(req.user.role)) {
|
| 15 |
+
return res.status(403).json({
|
| 16 |
+
status: 'fail',
|
| 17 |
+
message: 'You do not have permission to perform this action',
|
| 18 |
+
});
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
next();
|
| 22 |
+
};
|
| 23 |
+
};
|
| 24 |
+
|
| 25 |
+
// Middleware to check if user has a specific permission (for employees)
|
| 26 |
+
// Can accept a single permission string or an array of permissions
|
| 27 |
+
exports.checkPermission = (...permissions) => {
|
| 28 |
+
return (req, res, next) => {
|
| 29 |
+
if (!req.user) {
|
| 30 |
+
return res.status(401).json({
|
| 31 |
+
status: 'fail',
|
| 32 |
+
message: 'You must be logged in to access this resource',
|
| 33 |
+
});
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
// Admins have all permissions
|
| 37 |
+
if (req.user.role === 'admin') {
|
| 38 |
+
return next();
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
// Special case: Vendors are implicitly allowed to manage products and update fulfillment
|
| 42 |
+
const userRole = (req.user.role || '').toLowerCase();
|
| 43 |
+
|
| 44 |
+
const vendorPermissions = ['manage_products', 'update_item_fulfillment'];
|
| 45 |
+
|
| 46 |
+
// For vendors, if any of the required permissions are vendor-implicit, allow it
|
| 47 |
+
if (userRole === 'vendor') {
|
| 48 |
+
const hasVendorImplicit = permissions.some((perm) =>
|
| 49 |
+
vendorPermissions.includes(perm),
|
| 50 |
+
);
|
| 51 |
+
if (hasVendorImplicit) return next();
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
// Check if user has ANY of the required permissions
|
| 55 |
+
const userPermissions = req.user.permissions || [];
|
| 56 |
+
const hasPermission = permissions.some((perm) =>
|
| 57 |
+
userPermissions.includes(perm),
|
| 58 |
+
);
|
| 59 |
+
|
| 60 |
+
if (!hasPermission) {
|
| 61 |
+
return res.status(403).json({
|
| 62 |
+
status: 'fail',
|
| 63 |
+
message: `You do not have the required permissions to perform this action. Required: ${permissions.join(' or ')}`,
|
| 64 |
+
});
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
next();
|
| 68 |
+
};
|
| 69 |
+
};
|
| 70 |
+
|
| 71 |
+
// Middleware to verify vendor owns the resource
|
| 72 |
+
exports.isVendorOwner = (resourceType) => {
|
| 73 |
+
return async (req, res, next) => {
|
| 74 |
+
if (!req.user) {
|
| 75 |
+
return res.status(401).json({
|
| 76 |
+
status: 'fail',
|
| 77 |
+
message: 'You must be logged in to access this resource',
|
| 78 |
+
});
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
// Admins can access all resources
|
| 82 |
+
if (req.user.role === 'admin') {
|
| 83 |
+
return next();
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
// Only vendors need ownership verification
|
| 87 |
+
if (req.user.role !== 'vendor') {
|
| 88 |
+
return res.status(403).json({
|
| 89 |
+
status: 'fail',
|
| 90 |
+
message: 'This action is only available to vendors',
|
| 91 |
+
});
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
try {
|
| 95 |
+
let resource;
|
| 96 |
+
const resourceId = req.params.id;
|
| 97 |
+
|
| 98 |
+
// Load the resource based on type
|
| 99 |
+
if (resourceType === 'product') {
|
| 100 |
+
const Product = require('../models/productModel');
|
| 101 |
+
resource = await Product.findById(resourceId);
|
| 102 |
+
}
|
| 103 |
+
// Add more resource types as needed
|
| 104 |
+
|
| 105 |
+
if (!resource) {
|
| 106 |
+
return res.status(404).json({
|
| 107 |
+
status: 'fail',
|
| 108 |
+
message: `${resourceType} not found`,
|
| 109 |
+
});
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
// Check if vendor owns this resource
|
| 113 |
+
// Assuming products have a 'vendor' field that references the user
|
| 114 |
+
if (
|
| 115 |
+
resource.vendor &&
|
| 116 |
+
resource.vendor.toString() !== req.user._id.toString()
|
| 117 |
+
) {
|
| 118 |
+
return res.status(403).json({
|
| 119 |
+
status: 'fail',
|
| 120 |
+
message: `You can only modify your own ${resourceType}s`,
|
| 121 |
+
});
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
next();
|
| 125 |
+
} catch (err) {
|
| 126 |
+
res.status(500).json({
|
| 127 |
+
status: 'error',
|
| 128 |
+
message: 'Error verifying resource ownership',
|
| 129 |
+
error: err.message,
|
| 130 |
+
});
|
| 131 |
+
}
|
| 132 |
+
};
|
| 133 |
+
};
|
| 134 |
+
|
| 135 |
+
// Middleware to ensure user account is active
|
| 136 |
+
exports.isActiveUser = (req, res, next) => {
|
| 137 |
+
if (!req.user) {
|
| 138 |
+
return res.status(401).json({
|
| 139 |
+
status: 'fail',
|
| 140 |
+
message: 'You must be logged in to access this resource',
|
| 141 |
+
});
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
if (!req.user.isActive) {
|
| 145 |
+
return res.status(403).json({
|
| 146 |
+
status: 'fail',
|
| 147 |
+
message: 'Your account has been deactivated. Please contact support.',
|
| 148 |
+
});
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
next();
|
| 152 |
+
};
|
| 153 |
+
|
| 154 |
+
// Combined middleware: protect + isActive (commonly used together)
|
| 155 |
+
exports.protectActive = async (req, res, next) => {
|
| 156 |
+
const { protect } = require('../controllers/authController');
|
| 157 |
+
|
| 158 |
+
// First run protect middleware
|
| 159 |
+
await new Promise((resolve, reject) => {
|
| 160 |
+
protect(req, res, (err) => {
|
| 161 |
+
if (err) reject(err);
|
| 162 |
+
else resolve();
|
| 163 |
+
});
|
| 164 |
+
}).catch((err) => {
|
| 165 |
+
return res.status(401).json({
|
| 166 |
+
status: 'fail',
|
| 167 |
+
message: 'Authentication failed',
|
| 168 |
+
});
|
| 169 |
+
});
|
| 170 |
+
|
| 171 |
+
// Then check if active
|
| 172 |
+
if (!req.user.isActive) {
|
| 173 |
+
return res.status(403).json({
|
| 174 |
+
status: 'fail',
|
| 175 |
+
message: 'Your account has been deactivated. Please contact support.',
|
| 176 |
+
});
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
next();
|
| 180 |
+
};
|