Samoulla Sync Bot commited on
Commit
48bc1c7
·
0 Parent(s):

Auto-deploy Samoulla Backend: c82f2c9fc666cea51323d5ea793409fcf6d80b82

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitignore +3 -0
  2. Dockerfile +22 -0
  3. README.md +13 -0
  4. app.js +179 -0
  5. broken-url.txt +1 -0
  6. config/cloudinary.js +216 -0
  7. config/samoulla-490417-daa7176c0091.json +13 -0
  8. controllers/.DS_Store +0 -0
  9. controllers/addressController.js +87 -0
  10. controllers/authController.js +537 -0
  11. controllers/brandController.js +162 -0
  12. controllers/cartController.js +279 -0
  13. controllers/categoryController.js +208 -0
  14. controllers/contentController.js +705 -0
  15. controllers/favoriteController.js +106 -0
  16. controllers/financeController.js +1711 -0
  17. controllers/imageController.js +210 -0
  18. controllers/newsletterController.js +157 -0
  19. controllers/notificationController.js +825 -0
  20. controllers/orderController.js +1317 -0
  21. controllers/paymentController.js +516 -0
  22. controllers/productController.js +2191 -0
  23. controllers/promoController.js +225 -0
  24. controllers/providerController.js +317 -0
  25. controllers/returnRequestController.js +85 -0
  26. controllers/reviewController.js +129 -0
  27. controllers/settingsController.js +44 -0
  28. controllers/shippingController.js +179 -0
  29. controllers/userManagementController.js +473 -0
  30. controllers/usersController.js +191 -0
  31. controllers/vendorRequestController.js +212 -0
  32. controllers/visitController.js +211 -0
  33. devData/.DS_Store +0 -0
  34. devData/brands.json +21 -0
  35. devData/broken-images.json +1 -0
  36. devData/categories.json +39 -0
  37. devData/dump-broken.js +18 -0
  38. devData/fix-provider-ids.js +57 -0
  39. devData/import-brands.js +54 -0
  40. devData/import-categories.js +84 -0
  41. devData/importProducts.js +55 -0
  42. devData/inspect-broken.js +17 -0
  43. devData/list-providers.js +15 -0
  44. devData/products-sample.json +448 -0
  45. devData/providers-dump.json +14 -0
  46. devData/shift-products.js +64 -0
  47. devData/test-precise.js +28 -0
  48. image-formats.json +5 -0
  49. middlewares/audioUploadMiddleware.js +38 -0
  50. 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
+ '&': '&amp;',
2109
+ '<': '&lt;',
2110
+ '>': '&gt;',
2111
+ '"': '&quot;',
2112
+ "'": '&apos;',
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
+ '&': '&amp;',
2124
+ '<': '&lt;',
2125
+ '>': '&gt;',
2126
+ '"': '&quot;',
2127
+ "'": '&apos;',
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
+ '&': '&amp;',
2139
+ '<': '&lt;',
2140
+ '>': '&gt;',
2141
+ '"': '&quot;',
2142
+ "'": '&apos;',
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
+ '&': '&amp;',
2155
+ '<': '&lt;',
2156
+ '>': '&gt;',
2157
+ '"': '&quot;',
2158
+ "'": '&apos;',
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
+ };