first commit
This commit is contained in:
4
.dockerignore
Normal file
4
.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
npm-debug.log
|
||||||
|
.git
|
||||||
|
.env
|
||||||
19
.env.example
Normal file
19
.env.example
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# Server Configuration
|
||||||
|
NODE_ENV=development
|
||||||
|
PORT=3000
|
||||||
|
HOST=localhost
|
||||||
|
|
||||||
|
# Database Configuration
|
||||||
|
DATABASE_URL="postgresql://username:password@localhost:5432/vaishnavi_db?schema=public"
|
||||||
|
MONGODB_URI="mongodb://localhost:27017/vaishnavi_products"
|
||||||
|
REDIS_URL="redis://localhost:6379"
|
||||||
|
|
||||||
|
# JWT Configuration
|
||||||
|
JWT_SECRET="your-super-secret-jwt-key-change-this-in-production"
|
||||||
|
JWT_EXPIRES_IN="7d"
|
||||||
|
|
||||||
|
# AWS S3 Configuration
|
||||||
|
AWS_ACCESS_KEY_ID="your-aws-access-key"
|
||||||
|
AWS_SECRET_ACCESS_KEY="your-aws-secret-key"
|
||||||
|
AWS_REGION="us-east-1"
|
||||||
|
AWS_S3_BUCKET="vaishnavi-files"
|
||||||
37
.eslintrc.js
Normal file
37
.eslintrc.js
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
module.exports = {
|
||||||
|
env: {
|
||||||
|
node: true,
|
||||||
|
es2021: true,
|
||||||
|
jest: true,
|
||||||
|
},
|
||||||
|
extends: [
|
||||||
|
'eslint:recommended',
|
||||||
|
],
|
||||||
|
parserOptions: {
|
||||||
|
ecmaVersion: 2021,
|
||||||
|
sourceType: 'module',
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'no-console': 'warn',
|
||||||
|
'no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
||||||
|
'prefer-const': 'error',
|
||||||
|
'no-var': 'error',
|
||||||
|
'object-shorthand': 'error',
|
||||||
|
'prefer-template': 'error',
|
||||||
|
'template-curly-spacing': 'error',
|
||||||
|
'arrow-spacing': 'error',
|
||||||
|
'comma-dangle': ['error', 'always-multiline'],
|
||||||
|
'quotes': ['error', 'single', { avoidEscape: true }],
|
||||||
|
'semi': ['error', 'always'],
|
||||||
|
'indent': ['error', 2],
|
||||||
|
'no-trailing-spaces': 'error',
|
||||||
|
'eol-last': 'error',
|
||||||
|
},
|
||||||
|
ignorePatterns: [
|
||||||
|
'node_modules/',
|
||||||
|
'dist/',
|
||||||
|
'build/',
|
||||||
|
'coverage/',
|
||||||
|
'*.min.js',
|
||||||
|
],
|
||||||
|
};
|
||||||
132
.gitignore
vendored
Normal file
132
.gitignore
vendored
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
pids
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# Coverage directory used by tools like istanbul
|
||||||
|
coverage/
|
||||||
|
*.lcov
|
||||||
|
|
||||||
|
# nyc test coverage
|
||||||
|
.nyc_output
|
||||||
|
|
||||||
|
# Grunt intermediate storage
|
||||||
|
.grunt
|
||||||
|
|
||||||
|
# Bower dependency directory
|
||||||
|
bower_components
|
||||||
|
|
||||||
|
# node-waf configuration
|
||||||
|
.lock-wscript
|
||||||
|
|
||||||
|
# Compiled binary addons
|
||||||
|
build/Release
|
||||||
|
|
||||||
|
# Dependency directories
|
||||||
|
node_modules/
|
||||||
|
jspm_packages/
|
||||||
|
|
||||||
|
# TypeScript cache
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Optional npm cache directory
|
||||||
|
.npm
|
||||||
|
|
||||||
|
# Optional eslint cache
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
# Microbundle cache
|
||||||
|
.rpt2_cache/
|
||||||
|
.rts2_cache_cjs/
|
||||||
|
.rts2_cache_es/
|
||||||
|
.rts2_cache_umd/
|
||||||
|
|
||||||
|
# Optional REPL history
|
||||||
|
.node_repl_history
|
||||||
|
|
||||||
|
# Output of 'npm pack'
|
||||||
|
*.tgz
|
||||||
|
|
||||||
|
# Yarn Integrity file
|
||||||
|
.yarn-integrity
|
||||||
|
|
||||||
|
# dotenv environment variables file
|
||||||
|
.env
|
||||||
|
.env.test
|
||||||
|
|
||||||
|
# parcel-bundler cache
|
||||||
|
.cache
|
||||||
|
.parcel-cache
|
||||||
|
|
||||||
|
# Next.js build output
|
||||||
|
.next
|
||||||
|
|
||||||
|
# Nuxt.js build / generate output
|
||||||
|
.nuxt
|
||||||
|
dist
|
||||||
|
|
||||||
|
# Gatsby files
|
||||||
|
.cache/
|
||||||
|
public
|
||||||
|
|
||||||
|
# Storybook build outputs
|
||||||
|
.out
|
||||||
|
.storybook-out
|
||||||
|
|
||||||
|
# Temporary folders
|
||||||
|
tmp/
|
||||||
|
temp/
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS generated files
|
||||||
|
.DS_Store
|
||||||
|
.DS_Store?
|
||||||
|
._*
|
||||||
|
.Spotlight-V100
|
||||||
|
.Trashes
|
||||||
|
ehthumbs.db
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Prisma
|
||||||
|
prisma/migrations/
|
||||||
|
|
||||||
|
# Uploads (if storing locally)
|
||||||
|
uploads/
|
||||||
|
public/uploads/
|
||||||
|
|
||||||
|
# Database
|
||||||
|
*.sqlite
|
||||||
|
*.db
|
||||||
|
|
||||||
|
/src/generated/prisma
|
||||||
|
|
||||||
|
|
||||||
|
uploads/
|
||||||
11
.prettierrc
Normal file
11
.prettierrc
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"singleQuote": true,
|
||||||
|
"printWidth": 80,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"useTabs": false,
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"arrowParens": "avoid",
|
||||||
|
"endOfLine": "lf"
|
||||||
|
}
|
||||||
586
CouponGuide.md
Normal file
586
CouponGuide.md
Normal file
@@ -0,0 +1,586 @@
|
|||||||
|
# 🎟️ Complete Coupon System Guide
|
||||||
|
|
||||||
|
## 📦 What's Included
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
1. **Admin Coupon Controller** - Full CRUD + Statistics
|
||||||
|
2. **User Coupon Controller** - Validation & Application
|
||||||
|
3. **Coupon Routes** - Both admin and user endpoints
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
1. **CouponApply Component** - For checkout page
|
||||||
|
2. **Available Coupons Modal** - Browse & select coupons
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Setup Instructions
|
||||||
|
|
||||||
|
### Step 1: Backend Setup
|
||||||
|
|
||||||
|
#### 1.1 Add Controllers
|
||||||
|
|
||||||
|
Create two controller files:
|
||||||
|
|
||||||
|
**File 1:** `src/controllers/admin/couponController.js`
|
||||||
|
(Use the `couponController_Admin.js` file)
|
||||||
|
|
||||||
|
**File 2:** `src/controllers/couponController.js`
|
||||||
|
(Use the `couponController_User.js` file)
|
||||||
|
|
||||||
|
#### 1.2 Add Routes
|
||||||
|
|
||||||
|
Create: `src/routes/couponRoutes.js`
|
||||||
|
(Use the `couponRoutes.js` file)
|
||||||
|
|
||||||
|
#### 1.3 Register Routes in App
|
||||||
|
|
||||||
|
In your `server.js` or `app.js`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const couponRoutes = require('./routes/couponRoutes');
|
||||||
|
|
||||||
|
app.use('/api/coupons', couponRoutes);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 2: Frontend Setup
|
||||||
|
|
||||||
|
#### 2.1 Create Component
|
||||||
|
|
||||||
|
Create: `src/components/CouponApply.jsx`
|
||||||
|
(Use the `CouponApply.jsx` file)
|
||||||
|
|
||||||
|
#### 2.2 Use in Checkout Page
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// pages/Checkout.jsx
|
||||||
|
|
||||||
|
import CouponApply from '../components/CouponApply';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
const Checkout = () => {
|
||||||
|
const [appliedCoupon, setAppliedCoupon] = useState(null);
|
||||||
|
const [orderTotal, setOrderTotal] = useState(2599);
|
||||||
|
|
||||||
|
const handleCouponApplied = (couponData) => {
|
||||||
|
setAppliedCoupon(couponData);
|
||||||
|
setOrderTotal(couponData.finalAmount);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCouponRemoved = () => {
|
||||||
|
setAppliedCoupon(null);
|
||||||
|
setOrderTotal(2599); // Reset to original amount
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto p-6">
|
||||||
|
{/* Cart Items */}
|
||||||
|
|
||||||
|
{/* Coupon Section */}
|
||||||
|
<CouponApply
|
||||||
|
orderAmount={orderTotal}
|
||||||
|
onCouponApplied={handleCouponApplied}
|
||||||
|
onCouponRemoved={handleCouponRemoved}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Order Summary */}
|
||||||
|
<div className="mt-6 bg-white p-6 rounded-lg">
|
||||||
|
<div className="flex justify-between mb-2">
|
||||||
|
<span>Subtotal:</span>
|
||||||
|
<span>₹2,599</span>
|
||||||
|
</div>
|
||||||
|
{appliedCoupon && (
|
||||||
|
<div className="flex justify-between mb-2 text-green-600">
|
||||||
|
<span>Discount ({appliedCoupon.couponCode}):</span>
|
||||||
|
<span>-₹{appliedCoupon.discountAmount}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-between text-xl font-bold border-t pt-2">
|
||||||
|
<span>Total:</span>
|
||||||
|
<span>₹{orderTotal.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📡 API Endpoints
|
||||||
|
|
||||||
|
### Admin Endpoints
|
||||||
|
|
||||||
|
#### 1. Get All Coupons
|
||||||
|
```http
|
||||||
|
GET /api/coupons/admin?page=1&limit=20&isActive=true&type=PERCENTAGE&search=SAVE
|
||||||
|
Authorization: Bearer <admin_token>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": true,
|
||||||
|
"message": "Coupons fetched successfully",
|
||||||
|
"data": {
|
||||||
|
"coupons": [
|
||||||
|
{
|
||||||
|
"id": "...",
|
||||||
|
"code": "SAVE20",
|
||||||
|
"description": "20% off on all items",
|
||||||
|
"type": "PERCENTAGE",
|
||||||
|
"value": 20,
|
||||||
|
"minOrderAmount": 1000,
|
||||||
|
"maxUses": 100,
|
||||||
|
"usedCount": 25,
|
||||||
|
"usagePercentage": 25,
|
||||||
|
"isExpired": false,
|
||||||
|
"remainingUses": 75,
|
||||||
|
"isActive": true,
|
||||||
|
"validFrom": "2024-01-01",
|
||||||
|
"validUntil": "2024-12-31"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"pagination": {
|
||||||
|
"total": 50,
|
||||||
|
"page": 1,
|
||||||
|
"limit": 20,
|
||||||
|
"pages": 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Create Coupon
|
||||||
|
```http
|
||||||
|
POST /api/coupons/admin
|
||||||
|
Authorization: Bearer <admin_token>
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"code": "SAVE20",
|
||||||
|
"description": "Get 20% off on orders above ₹1000",
|
||||||
|
"type": "PERCENTAGE",
|
||||||
|
"value": 20,
|
||||||
|
"minOrderAmount": 1000,
|
||||||
|
"maxUses": 100,
|
||||||
|
"validFrom": "2024-01-01T00:00:00Z",
|
||||||
|
"validUntil": "2024-12-31T23:59:59Z",
|
||||||
|
"isActive": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Coupon Types:**
|
||||||
|
- `PERCENTAGE` - Percentage discount (e.g., 20%)
|
||||||
|
- `FIXED_AMOUNT` - Fixed amount off (e.g., ₹500)
|
||||||
|
- `FREE_SHIPPING` - Free shipping
|
||||||
|
|
||||||
|
#### 3. Update Coupon
|
||||||
|
```http
|
||||||
|
PUT /api/coupons/admin/:id
|
||||||
|
Authorization: Bearer <admin_token>
|
||||||
|
|
||||||
|
{
|
||||||
|
"value": 25,
|
||||||
|
"maxUses": 150
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. Toggle Coupon Status
|
||||||
|
```http
|
||||||
|
PATCH /api/coupons/admin/:id/toggle
|
||||||
|
Authorization: Bearer <admin_token>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 5. Get Coupon Statistics
|
||||||
|
```http
|
||||||
|
GET /api/coupons/admin/stats
|
||||||
|
Authorization: Bearer <admin_token>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": true,
|
||||||
|
"data": {
|
||||||
|
"totalCoupons": 50,
|
||||||
|
"activeCoupons": 35,
|
||||||
|
"expiredCoupons": 10,
|
||||||
|
"totalRedemptions": 2450,
|
||||||
|
"mostUsedCoupons": [...]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Endpoints
|
||||||
|
|
||||||
|
#### 1. Get Available Coupons
|
||||||
|
```http
|
||||||
|
GET /api/coupons/available?orderAmount=2599
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": true,
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"code": "SAVE20",
|
||||||
|
"description": "20% off on orders above ₹1000",
|
||||||
|
"type": "PERCENTAGE",
|
||||||
|
"value": 20,
|
||||||
|
"minOrderAmount": 1000,
|
||||||
|
"discountPreview": "20% OFF",
|
||||||
|
"remainingUses": 75,
|
||||||
|
"validUntil": "2024-12-31"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Validate Coupon
|
||||||
|
```http
|
||||||
|
POST /api/coupons/validate
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"code": "SAVE20",
|
||||||
|
"orderAmount": 2599
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": true,
|
||||||
|
"message": "Coupon applied successfully",
|
||||||
|
"data": {
|
||||||
|
"couponCode": "SAVE20",
|
||||||
|
"couponType": "PERCENTAGE",
|
||||||
|
"discountAmount": 519.80,
|
||||||
|
"originalAmount": 2599,
|
||||||
|
"finalAmount": 2079.20,
|
||||||
|
"savings": 519.80,
|
||||||
|
"savingsPercentage": 20
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. Apply Coupon to Order
|
||||||
|
```http
|
||||||
|
POST /api/coupons/apply
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
|
||||||
|
{
|
||||||
|
"couponCode": "SAVE20",
|
||||||
|
"orderId": "order_123"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. Remove Coupon
|
||||||
|
```http
|
||||||
|
POST /api/coupons/remove
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
|
||||||
|
{
|
||||||
|
"orderId": "order_123"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💡 Usage Examples
|
||||||
|
|
||||||
|
### Example 1: Admin Creates Percentage Coupon
|
||||||
|
```javascript
|
||||||
|
const createCoupon = async () => {
|
||||||
|
const response = await axios.post(
|
||||||
|
'http://localhost:3000/api/coupons/admin',
|
||||||
|
{
|
||||||
|
code: 'WELCOME10',
|
||||||
|
description: 'Get 10% off on your first order',
|
||||||
|
type: 'PERCENTAGE',
|
||||||
|
value: 10,
|
||||||
|
minOrderAmount: 500,
|
||||||
|
maxUses: 1000,
|
||||||
|
validFrom: new Date(),
|
||||||
|
validUntil: new Date('2024-12-31'),
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${adminToken}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example 2: Admin Creates Fixed Amount Coupon
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
code: 'FLAT500',
|
||||||
|
description: 'Flat ₹500 off on orders above ₹2000',
|
||||||
|
type: 'FIXED_AMOUNT',
|
||||||
|
value: 500,
|
||||||
|
minOrderAmount: 2000,
|
||||||
|
maxUses: 500,
|
||||||
|
validFrom: new Date(),
|
||||||
|
validUntil: new Date('2024-12-31'),
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example 3: Admin Creates Free Shipping Coupon
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
code: 'FREESHIP',
|
||||||
|
description: 'Free shipping on all orders',
|
||||||
|
type: 'FREE_SHIPPING',
|
||||||
|
value: 0,
|
||||||
|
minOrderAmount: null,
|
||||||
|
maxUses: null,
|
||||||
|
validFrom: new Date(),
|
||||||
|
validUntil: new Date('2024-12-31'),
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example 4: Customer Applies Coupon
|
||||||
|
```javascript
|
||||||
|
// In your checkout component
|
||||||
|
const [discount, setDiscount] = useState(0);
|
||||||
|
|
||||||
|
<CouponApply
|
||||||
|
orderAmount={2599}
|
||||||
|
onCouponApplied={(data) => {
|
||||||
|
setDiscount(data.discountAmount);
|
||||||
|
console.log('Saved:', data.savings);
|
||||||
|
}}
|
||||||
|
onCouponRemoved={() => {
|
||||||
|
setDiscount(0);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎨 Coupon Validation Rules
|
||||||
|
|
||||||
|
### Automatic Validations
|
||||||
|
|
||||||
|
1. **Active Status** - Must be active
|
||||||
|
2. **Date Range** - Must be within validFrom and validUntil
|
||||||
|
3. **Usage Limit** - Must have remaining uses
|
||||||
|
4. **Minimum Order** - Order amount must meet minimum requirement
|
||||||
|
5. **Code Uniqueness** - Code must be unique (enforced on creation)
|
||||||
|
|
||||||
|
### Error Messages
|
||||||
|
|
||||||
|
```
|
||||||
|
❌ "This coupon is no longer active"
|
||||||
|
❌ "This coupon is not yet valid"
|
||||||
|
❌ "This coupon has expired"
|
||||||
|
❌ "This coupon has reached its usage limit"
|
||||||
|
❌ "Minimum order amount of ₹1000 required"
|
||||||
|
❌ "Invalid coupon code"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Coupon Types Explained
|
||||||
|
|
||||||
|
### 1. PERCENTAGE Discount
|
||||||
|
```
|
||||||
|
Order Amount: ₹2,599
|
||||||
|
Coupon: 20% OFF
|
||||||
|
Discount: ₹519.80
|
||||||
|
Final Amount: ₹2,079.20
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. FIXED_AMOUNT Discount
|
||||||
|
```
|
||||||
|
Order Amount: ₹2,599
|
||||||
|
Coupon: ₹500 OFF
|
||||||
|
Discount: ₹500
|
||||||
|
Final Amount: ₹2,099
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. FREE_SHIPPING
|
||||||
|
```
|
||||||
|
Order Amount: ₹2,599
|
||||||
|
Shipping: ₹50
|
||||||
|
Coupon: FREE SHIPPING
|
||||||
|
Discount: ₹50
|
||||||
|
Final Amount: ₹2,549
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Advanced Features
|
||||||
|
|
||||||
|
### Feature 1: User-Specific Coupons
|
||||||
|
|
||||||
|
Add a `userId` field to restrict coupons:
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
model Coupon {
|
||||||
|
// ... existing fields
|
||||||
|
userId String? // Optional: restrict to specific user
|
||||||
|
user User? @relation(fields: [userId], references: [id])
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Feature 2: Product-Specific Coupons
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
model Coupon {
|
||||||
|
// ... existing fields
|
||||||
|
applicableCategories String[] // Only for these categories
|
||||||
|
applicableProducts String[] // Only for these products
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Feature 3: First-Time User Coupons
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// In validation logic
|
||||||
|
const userOrders = await prisma.order.count({
|
||||||
|
where: { userId, status: 'DELIVERED' }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (coupon.firstTimeOnly && userOrders > 0) {
|
||||||
|
return { isValid: false, message: 'Coupon only for first-time users' };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Testing Checklist
|
||||||
|
|
||||||
|
### Backend Testing
|
||||||
|
|
||||||
|
- [ ] Create percentage coupon
|
||||||
|
- [ ] Create fixed amount coupon
|
||||||
|
- [ ] Create free shipping coupon
|
||||||
|
- [ ] Validate active coupon
|
||||||
|
- [ ] Validate expired coupon
|
||||||
|
- [ ] Validate coupon below min order
|
||||||
|
- [ ] Validate coupon at usage limit
|
||||||
|
- [ ] Apply coupon to order
|
||||||
|
- [ ] Remove coupon from order
|
||||||
|
- [ ] Toggle coupon status
|
||||||
|
- [ ] Get coupon statistics
|
||||||
|
|
||||||
|
### Frontend Testing
|
||||||
|
|
||||||
|
- [ ] Apply valid coupon
|
||||||
|
- [ ] Apply invalid coupon
|
||||||
|
- [ ] View available coupons
|
||||||
|
- [ ] Remove applied coupon
|
||||||
|
- [ ] See discount in order summary
|
||||||
|
- [ ] Responsive design
|
||||||
|
- [ ] Loading states
|
||||||
|
- [ ] Error handling
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Common Use Cases
|
||||||
|
|
||||||
|
### Use Case 1: Flash Sale
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
code: 'FLASH50',
|
||||||
|
type: 'PERCENTAGE',
|
||||||
|
value: 50,
|
||||||
|
validFrom: new Date('2024-12-25T00:00:00'),
|
||||||
|
validUntil: new Date('2024-12-25T23:59:59'),
|
||||||
|
maxUses: 100
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Use Case 2: Minimum Purchase Promotion
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
code: 'BUY1000GET200',
|
||||||
|
type: 'FIXED_AMOUNT',
|
||||||
|
value: 200,
|
||||||
|
minOrderAmount: 1000,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Use Case 3: New Customer Welcome
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
code: 'WELCOME',
|
||||||
|
type: 'PERCENTAGE',
|
||||||
|
value: 15,
|
||||||
|
description: 'Welcome! Get 15% off on your first order',
|
||||||
|
maxUses: null, // Unlimited
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📈 Analytics & Reporting
|
||||||
|
|
||||||
|
Track coupon performance:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Get most successful coupons
|
||||||
|
const topCoupons = await prisma.coupon.findMany({
|
||||||
|
orderBy: { usedCount: 'desc' },
|
||||||
|
take: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate total savings given
|
||||||
|
const totalSavings = await calculateTotalDiscounts();
|
||||||
|
|
||||||
|
// Conversion rate
|
||||||
|
const conversionRate = (usedCount / impressions) * 100;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Your coupon system is ready! 🎉**
|
||||||
|
|
||||||
|
Full features:
|
||||||
|
✅ Admin CRUD operations
|
||||||
|
✅ Smart validation
|
||||||
|
✅ Multiple coupon types
|
||||||
|
✅ Usage tracking
|
||||||
|
✅ Statistics & analytics
|
||||||
|
✅ User-friendly UI
|
||||||
|
✅ Mobile responsive
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
1. User applies coupon "WELCOME10"
|
||||||
|
↓
|
||||||
|
2. CouponApply validates coupon
|
||||||
|
↓
|
||||||
|
3. Coupon data stored in state
|
||||||
|
{
|
||||||
|
couponCode: "WELCOME10",
|
||||||
|
discountAmount: 259.90,
|
||||||
|
...
|
||||||
|
}
|
||||||
|
↓
|
||||||
|
4. User clicks "Place Order"
|
||||||
|
↓
|
||||||
|
5. Frontend sends:
|
||||||
|
{
|
||||||
|
items: [...],
|
||||||
|
couponCode: "WELCOME10" ✅
|
||||||
|
}
|
||||||
|
↓
|
||||||
|
6. Backend validates coupon again
|
||||||
|
↓
|
||||||
|
7. Creates order in transaction:
|
||||||
|
- Creates order
|
||||||
|
- Increments coupon.usedCount ✅
|
||||||
|
↓
|
||||||
|
8. Admin panel shows:
|
||||||
|
"usedCount": 1 ✅
|
||||||
14
Dockerfile
Normal file
14
Dockerfile
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
FROM node:20-bullseye-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN npx prisma generate
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["node", "src/server.js"]
|
||||||
332
Inventory.md
Normal file
332
Inventory.md
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
# 📦 Complete Inventory Management System - Implementation Guide
|
||||||
|
|
||||||
|
## What You Get
|
||||||
|
|
||||||
|
✅ **Auto Stock Reduction** - Stock reduces when order delivered
|
||||||
|
✅ **Low Stock Alerts** - Dashboard shows products needing restock
|
||||||
|
✅ **Real Top Sellers** - Based on actual sales, not fake data
|
||||||
|
✅ **Inventory Logs** - Track every stock change
|
||||||
|
✅ **Stock Status** - OUT_OF_STOCK, CRITICAL, LOW, IN_STOCK
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Setup (5 Steps)
|
||||||
|
|
||||||
|
### Step 1: Update Prisma Schema
|
||||||
|
|
||||||
|
Add this to your `schema.prisma`:
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
model InventoryLog {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
|
||||||
|
productId String
|
||||||
|
productName String
|
||||||
|
|
||||||
|
type InventoryLogType
|
||||||
|
quantityChange Int
|
||||||
|
previousStock Int
|
||||||
|
newStock Int
|
||||||
|
|
||||||
|
orderId String?
|
||||||
|
adjustedBy String?
|
||||||
|
notes String?
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([productId])
|
||||||
|
@@index([orderId])
|
||||||
|
@@index([type])
|
||||||
|
@@map("inventory_logs")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum InventoryLogType {
|
||||||
|
SOLD
|
||||||
|
RESTOCK
|
||||||
|
ADJUSTMENT
|
||||||
|
RETURN
|
||||||
|
DAMAGED
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Run Migration
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx prisma migrate dev --name add_inventory_logs
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Add Inventory Service
|
||||||
|
|
||||||
|
Create `src/services/inventoryService.js` with the provided code.
|
||||||
|
|
||||||
|
### Step 4: Update Order Controller
|
||||||
|
|
||||||
|
Replace your order controller with the version that includes auto stock reduction.
|
||||||
|
|
||||||
|
### Step 5: Update Dashboard Controller
|
||||||
|
|
||||||
|
Replace dashboard controller to show real top sellers + low stock alerts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Dashboard API Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"totalUsers": 150,
|
||||||
|
"totalOrders": 450,
|
||||||
|
"totalProducts": 89,
|
||||||
|
"totalRevenue": 125000,
|
||||||
|
|
||||||
|
"inventory": {
|
||||||
|
"totalProducts": 89,
|
||||||
|
"outOfStock": 5,
|
||||||
|
"criticalStock": 8,
|
||||||
|
"lowStock": 12,
|
||||||
|
"inStock": 64
|
||||||
|
},
|
||||||
|
|
||||||
|
"topProducts": [
|
||||||
|
{
|
||||||
|
"_id": "prod123",
|
||||||
|
"name": "Rare Rabbit",
|
||||||
|
"basePrice": 2500,
|
||||||
|
"totalSold": 45,
|
||||||
|
"totalOrders": 32,
|
||||||
|
"revenue": 112500,
|
||||||
|
"stock": 12,
|
||||||
|
"stockStatus": "LOW",
|
||||||
|
"displayImage": "https://..."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
"lowStockProducts": [
|
||||||
|
{
|
||||||
|
"_id": "prod456",
|
||||||
|
"name": "Product Name",
|
||||||
|
"stock": 3,
|
||||||
|
"status": "CRITICAL",
|
||||||
|
"basePrice": 1500,
|
||||||
|
"displayImage": "https://..."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚙️ How Auto Stock Reduction Works
|
||||||
|
|
||||||
|
### When Admin Updates Order to DELIVERED:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Admin: PUT /api/admin/orders/:id/status
|
||||||
|
Body: { "status": "DELIVERED" }
|
||||||
|
|
||||||
|
2. Order Controller:
|
||||||
|
✅ Updates order status
|
||||||
|
✅ Creates status history
|
||||||
|
✅ Calls reduceStockOnDelivery()
|
||||||
|
|
||||||
|
3. Inventory Service:
|
||||||
|
✅ Gets order items
|
||||||
|
✅ For each item:
|
||||||
|
- Get product from MongoDB
|
||||||
|
- Calculate: newStock = currentStock - quantity
|
||||||
|
- Update product.stock in MongoDB
|
||||||
|
- Create inventory log in PostgreSQL
|
||||||
|
|
||||||
|
4. Response:
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "Order status updated",
|
||||||
|
"stockReduction": [
|
||||||
|
{
|
||||||
|
"productName": "Rare Rabbit",
|
||||||
|
"reduced": 1,
|
||||||
|
"previousStock": 15,
|
||||||
|
"newStock": 14
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Inventory Log Example
|
||||||
|
|
||||||
|
Every stock change creates a log:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "log123",
|
||||||
|
"productId": "prod456",
|
||||||
|
"productName": "Rare Rabbit",
|
||||||
|
"type": "SOLD",
|
||||||
|
"quantityChange": -1,
|
||||||
|
"previousStock": 15,
|
||||||
|
"newStock": 14,
|
||||||
|
"orderId": "order789",
|
||||||
|
"notes": "Order ORD1234567 delivered",
|
||||||
|
"createdAt": "2026-02-11T14:30:00.000Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Stock Status Logic
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
stock === 0 → OUT_OF_STOCK (Red)
|
||||||
|
stock <= 5 → CRITICAL (Orange)
|
||||||
|
stock <= 10 → LOW (Yellow)
|
||||||
|
stock > 10 → IN_STOCK (Green)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Top Selling Products Logic
|
||||||
|
|
||||||
|
### OLD (Wrong):
|
||||||
|
```javascript
|
||||||
|
// ❌ Used purchaseCount from MongoDB (never updated)
|
||||||
|
Product.find().sort({ purchaseCount: -1 })
|
||||||
|
```
|
||||||
|
|
||||||
|
### NEW (Correct):
|
||||||
|
```javascript
|
||||||
|
// ✅ Query actual order data from PostgreSQL
|
||||||
|
SELECT productId, SUM(quantity), COUNT(*)
|
||||||
|
FROM order_items
|
||||||
|
GROUP BY productId
|
||||||
|
ORDER BY SUM(quantity) DESC
|
||||||
|
|
||||||
|
// ✅ Join with MongoDB for product details
|
||||||
|
// ✅ Add stock info
|
||||||
|
// ✅ Calculate revenue
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎨 Frontend Display
|
||||||
|
|
||||||
|
### Dashboard - Top Sellers
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{topProducts.map(product => (
|
||||||
|
<div key={product._id} className="flex items-center gap-4 p-4 bg-white rounded-lg shadow">
|
||||||
|
<img src={product.displayImage} className="w-16 h-16 rounded" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="font-bold">{product.name}</h3>
|
||||||
|
<div className="flex items-center gap-4 text-sm text-gray-600">
|
||||||
|
<span>Sold: {product.totalSold}</span>
|
||||||
|
<span>Revenue: ₹{product.revenue.toLocaleString()}</span>
|
||||||
|
<span className={`px-2 py-1 rounded text-xs ${
|
||||||
|
product.stockStatus === 'OUT_OF_STOCK' ? 'bg-red-100 text-red-800' :
|
||||||
|
product.stockStatus === 'CRITICAL' ? 'bg-orange-100 text-orange-800' :
|
||||||
|
product.stockStatus === 'LOW' ? 'bg-yellow-100 text-yellow-800' :
|
||||||
|
'bg-green-100 text-green-800'
|
||||||
|
}`}>
|
||||||
|
{product.stock} in stock
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dashboard - Low Stock Alerts
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{lowStockProducts.length > 0 && (
|
||||||
|
<div className="bg-orange-50 border border-orange-200 rounded-lg p-6">
|
||||||
|
<h3 className="text-lg font-bold text-orange-900 mb-4">
|
||||||
|
⚠️ Low Stock Alert ({lowStockProducts.length})
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{lowStockProducts.map(product => (
|
||||||
|
<div key={product._id} className="flex items-center justify-between p-3 bg-white rounded">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<img src={product.displayImage} className="w-12 h-12 rounded" />
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold">{product.name}</p>
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
{product.stock === 0 ? 'Out of Stock' : `Only ${product.stock} left`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button className="px-4 py-2 bg-orange-600 text-white rounded">
|
||||||
|
Restock
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Manual Stock Adjustment (Future)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// POST /api/admin/inventory/adjust
|
||||||
|
{
|
||||||
|
"productId": "prod123",
|
||||||
|
"quantity": 50,
|
||||||
|
"type": "ADD", // ADD, REMOVE, SET
|
||||||
|
"notes": "Received new shipment"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📈 Benefits
|
||||||
|
|
||||||
|
1. **Accurate Inventory** - Always know real stock levels
|
||||||
|
2. **Prevent Overselling** - Stock reduces on delivery
|
||||||
|
3. **Early Warnings** - Low stock alerts
|
||||||
|
4. **Better Planning** - See what's selling
|
||||||
|
5. **Full Audit Trail** - Every change logged
|
||||||
|
6. **Automated** - No manual work needed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Testing Checklist
|
||||||
|
|
||||||
|
- [ ] Prisma migration ran successfully
|
||||||
|
- [ ] Place test order
|
||||||
|
- [ ] Mark order as DELIVERED
|
||||||
|
- [ ] Check product stock reduced
|
||||||
|
- [ ] Check inventory log created
|
||||||
|
- [ ] Dashboard shows correct stock
|
||||||
|
- [ ] Low stock products appear
|
||||||
|
- [ ] Top sellers show real data
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 What Happens
|
||||||
|
|
||||||
|
### Before (Old System):
|
||||||
|
```
|
||||||
|
Order Delivered → Nothing happens
|
||||||
|
Stock: Always same (never changes)
|
||||||
|
Top Products: Fake/old data
|
||||||
|
Dashboard: No inventory info
|
||||||
|
```
|
||||||
|
|
||||||
|
### After (New System):
|
||||||
|
```
|
||||||
|
Order Delivered → Auto stock reduction
|
||||||
|
Stock: Real-time accurate
|
||||||
|
Top Products: Based on actual sales
|
||||||
|
Dashboard: Full inventory overview
|
||||||
|
Alerts: Low stock warnings
|
||||||
|
Logs: Complete audit trail
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Your inventory system is now production-ready!** 📦✨
|
||||||
183
LOCAL_SETUP.md
Normal file
183
LOCAL_SETUP.md
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
# Local Development Setup Guide
|
||||||
|
|
||||||
|
This guide will help you set up the Vaishnavi Creation backend for local development without Docker.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
Make sure you have the following installed and running:
|
||||||
|
|
||||||
|
1. **Node.js 18+** ✅ (You have v22.14.0)
|
||||||
|
2. **PostgreSQL 15+** - Make sure it's running
|
||||||
|
3. **MongoDB 7+** - Make sure it's running
|
||||||
|
4. **Redis 7+** (Optional) - For caching and background jobs
|
||||||
|
|
||||||
|
## Step 1: Configure Environment Variables
|
||||||
|
|
||||||
|
1. Copy the environment template:
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Edit the `.env` file with your database credentials:
|
||||||
|
|
||||||
|
**For PostgreSQL**, update the DATABASE_URL:
|
||||||
|
```env
|
||||||
|
# Replace with your actual PostgreSQL credentials
|
||||||
|
DATABASE_URL="postgresql://username:password@localhost:5432/vaishnavi_db?schema=public"
|
||||||
|
```
|
||||||
|
|
||||||
|
Common PostgreSQL configurations:
|
||||||
|
- **Default user**: `postgres`
|
||||||
|
- **Default password**: `postgres` (or whatever you set)
|
||||||
|
- **Default port**: `5432`
|
||||||
|
- **Database name**: `vaishnavi_db` (we'll create this)
|
||||||
|
|
||||||
|
**For MongoDB**, update the MONGODB_URI:
|
||||||
|
```env
|
||||||
|
# Usually this is fine for local MongoDB
|
||||||
|
MONGODB_URI="mongodb://localhost:27017/vaishnavi_products"
|
||||||
|
```
|
||||||
|
|
||||||
|
**For Redis** (Optional):
|
||||||
|
```env
|
||||||
|
# Comment this out if you don't have Redis installed
|
||||||
|
REDIS_URL="redis://localhost:6379"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 2: Create PostgreSQL Database
|
||||||
|
|
||||||
|
Connect to PostgreSQL and create the database:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Connect to PostgreSQL (replace with your credentials)
|
||||||
|
psql -U postgres
|
||||||
|
|
||||||
|
-- Create the database
|
||||||
|
CREATE DATABASE vaishnavi_db;
|
||||||
|
|
||||||
|
-- Exit psql
|
||||||
|
\q
|
||||||
|
```
|
||||||
|
|
||||||
|
**Windows Users**: If you have PostgreSQL installed via installer, you might need to use pgAdmin or the command prompt with full path.
|
||||||
|
|
||||||
|
## Step 3: Run the Setup Script
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run setup
|
||||||
|
```
|
||||||
|
|
||||||
|
This script will:
|
||||||
|
- ✅ Check Node.js version
|
||||||
|
- ✅ Install dependencies
|
||||||
|
- ✅ Generate Prisma client
|
||||||
|
- ✅ Create database schema
|
||||||
|
- ✅ Seed the database with sample data
|
||||||
|
|
||||||
|
## Step 4: Start Development Server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
The API will be available at `http://localhost:3000`
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### PostgreSQL Connection Issues
|
||||||
|
|
||||||
|
1. **Make sure PostgreSQL is running**:
|
||||||
|
- **Windows**: Check Services or start PostgreSQL service
|
||||||
|
- **Linux/Mac**: `sudo systemctl start postgresql` or `brew services start postgresql`
|
||||||
|
|
||||||
|
2. **Check your connection string**:
|
||||||
|
```env
|
||||||
|
# Format: postgresql://username:password@host:port/database
|
||||||
|
DATABASE_URL="postgresql://postgres:yourpassword@localhost:5432/vaishnavi_db?schema=public"
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Test connection manually**:
|
||||||
|
```bash
|
||||||
|
# Test if PostgreSQL is accessible
|
||||||
|
psql -U postgres -h localhost -p 5432 -c "SELECT version();"
|
||||||
|
```
|
||||||
|
|
||||||
|
### MongoDB Connection Issues
|
||||||
|
|
||||||
|
1. **Make sure MongoDB is running**:
|
||||||
|
- **Windows**: Check Services or start MongoDB service
|
||||||
|
- **Linux**: `sudo systemctl start mongod`
|
||||||
|
- **Mac**: `brew services start mongodb-community`
|
||||||
|
|
||||||
|
2. **Test MongoDB connection**:
|
||||||
|
```bash
|
||||||
|
# Connect to MongoDB
|
||||||
|
mongosh
|
||||||
|
# or
|
||||||
|
mongo
|
||||||
|
```
|
||||||
|
|
||||||
|
### Redis Connection Issues (Optional)
|
||||||
|
|
||||||
|
If you don't have Redis installed or don't want to use it:
|
||||||
|
|
||||||
|
1. Comment out the Redis URL in your `.env` file:
|
||||||
|
```env
|
||||||
|
# REDIS_URL="redis://localhost:6379"
|
||||||
|
```
|
||||||
|
|
||||||
|
2. The application will work without Redis, but caching and background jobs won't be available.
|
||||||
|
|
||||||
|
## Manual Setup (Alternative)
|
||||||
|
|
||||||
|
If the setup script doesn't work, you can run the commands manually:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Generate Prisma client
|
||||||
|
npx prisma generate
|
||||||
|
|
||||||
|
# Create database schema
|
||||||
|
npx prisma db push
|
||||||
|
|
||||||
|
# Seed database
|
||||||
|
npm run db:seed
|
||||||
|
|
||||||
|
# Start development server
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Once everything is set up, you can verify by:
|
||||||
|
|
||||||
|
1. **Health Check**: Visit `http://localhost:3000/health`
|
||||||
|
2. **API Root**: Visit `http://localhost:3000/`
|
||||||
|
3. **Database Studio**: Run `npm run db:studio` to view your data
|
||||||
|
|
||||||
|
## Default Users
|
||||||
|
|
||||||
|
After seeding, you'll have these test users:
|
||||||
|
|
||||||
|
- **Admin**: `admin@vaishnavi.com` / `admin123`
|
||||||
|
- **Customer**: `customer@example.com` / `customer123`
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. Test the API endpoints using Postman or curl
|
||||||
|
2. Set up your frontend application
|
||||||
|
3. Configure AWS S3 for file uploads (optional)
|
||||||
|
4. Set up email services for notifications (optional)
|
||||||
|
|
||||||
|
## Getting Help
|
||||||
|
|
||||||
|
If you encounter issues:
|
||||||
|
|
||||||
|
1. Check the logs in your terminal
|
||||||
|
2. Verify all services are running
|
||||||
|
3. Double-check your `.env` configuration
|
||||||
|
4. Make sure you have the correct database permissions
|
||||||
|
|
||||||
|
Happy coding! 🚀
|
||||||
289
README.md
Normal file
289
README.md
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
# Vaishnavi Creation - E-commerce Backend API
|
||||||
|
|
||||||
|
A comprehensive e-commerce backend API built with Node.js, Express, PostgreSQL, and MongoDB, designed for fashion and wardrobe management with AI-powered features.
|
||||||
|
|
||||||
|
## 🚀 Tech Stack
|
||||||
|
|
||||||
|
- **Backend**: Node.js 18+, Express.js
|
||||||
|
- **Databases**:
|
||||||
|
- PostgreSQL (transactional data) with Prisma ORM
|
||||||
|
- MongoDB (flexible product/wardrobe documents) with Mongoose
|
||||||
|
- **Caching & Jobs**: Redis with BullMQ
|
||||||
|
- **File Storage**: AWS S3
|
||||||
|
- **Authentication**: JWT with bcrypt
|
||||||
|
- **Containerization**: Docker
|
||||||
|
- **CI/CD**: GitHub Actions
|
||||||
|
|
||||||
|
## 📁 Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
vaishnavi-backend/
|
||||||
|
├── src/
|
||||||
|
│ ├── config/ # Database and app configuration
|
||||||
|
│ ├── controllers/ # Route controllers
|
||||||
|
│ ├── middleware/ # Custom middleware (auth, error handling)
|
||||||
|
│ ├── models/ # Database models
|
||||||
|
│ │ └── mongodb/ # MongoDB models (Product, Wardrobe)
|
||||||
|
│ ├── routes/ # API routes
|
||||||
|
│ ├── services/ # Business logic services
|
||||||
|
│ ├── jobs/ # Background job processors
|
||||||
|
│ ├── utils/ # Utility functions
|
||||||
|
│ ├── types/ # TypeScript type definitions
|
||||||
|
│ └── server.js # Application entry point
|
||||||
|
├── prisma/ # Prisma schema and migrations
|
||||||
|
├── tests/ # Test files
|
||||||
|
└── package.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🛠️ Setup & Installation
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Node.js 18+
|
||||||
|
- PostgreSQL 15+ (running locally)
|
||||||
|
- MongoDB 7+ (running locally)
|
||||||
|
- Redis 7+ (optional, for caching and job queues)
|
||||||
|
|
||||||
|
### 1. Clone and Install Dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <repository-url>
|
||||||
|
cd vaishnavi-backend
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Environment Configuration
|
||||||
|
|
||||||
|
Copy the environment template and configure your variables:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Edit `.env` with your local database configuration:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Database URLs - Update with your local credentials
|
||||||
|
DATABASE_URL="postgresql://postgres:yourpassword@localhost:5432/vaishnavi_db"
|
||||||
|
MONGODB_URI="mongodb://localhost:27017/vaishnavi_products"
|
||||||
|
REDIS_URL="redis://localhost:6379"
|
||||||
|
|
||||||
|
# JWT Secrets - Change these in production!
|
||||||
|
JWT_SECRET="your-super-secret-jwt-key"
|
||||||
|
JWT_REFRESH_SECRET="your-super-secret-refresh-key"
|
||||||
|
|
||||||
|
# AWS S3 Configuration (Optional)
|
||||||
|
AWS_ACCESS_KEY_ID="your-aws-access-key"
|
||||||
|
AWS_SECRET_ACCESS_KEY="your-aws-secret-key"
|
||||||
|
AWS_S3_BUCKET="vaishnavi-files"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Database Setup
|
||||||
|
|
||||||
|
#### PostgreSQL Setup
|
||||||
|
|
||||||
|
1. **Create the database**:
|
||||||
|
```sql
|
||||||
|
-- Connect to PostgreSQL and create the database
|
||||||
|
CREATE DATABASE vaishnavi_db;
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Generate Prisma client and run migrations**:
|
||||||
|
```bash
|
||||||
|
# Generate Prisma client
|
||||||
|
npm run db:generate
|
||||||
|
|
||||||
|
# Run migrations to create tables
|
||||||
|
npm run db:migrate
|
||||||
|
|
||||||
|
# Seed database with sample data
|
||||||
|
npm run db:seed
|
||||||
|
```
|
||||||
|
|
||||||
|
#### MongoDB Setup
|
||||||
|
|
||||||
|
MongoDB will automatically create collections when first accessed. Make sure your MongoDB service is running on the default port (27017).
|
||||||
|
|
||||||
|
#### Redis Setup (Optional)
|
||||||
|
|
||||||
|
If you have Redis installed locally, make sure it's running on port 6379. If not, you can comment out the Redis URL in your `.env` file.
|
||||||
|
|
||||||
|
### 4. Start Development Server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
The API will be available at `http://localhost:3000`
|
||||||
|
|
||||||
|
## 🔧 Troubleshooting
|
||||||
|
|
||||||
|
### Database Connection Issues
|
||||||
|
|
||||||
|
1. **PostgreSQL Connection Error**:
|
||||||
|
- Ensure PostgreSQL is running: `sudo systemctl start postgresql` (Linux) or start PostgreSQL service (Windows)
|
||||||
|
- Check your connection string in `.env`
|
||||||
|
- Verify the database exists: `psql -U postgres -c "CREATE DATABASE vaishnavi_db;"`
|
||||||
|
|
||||||
|
2. **MongoDB Connection Error**:
|
||||||
|
- Ensure MongoDB is running: `sudo systemctl start mongod` (Linux) or start MongoDB service (Windows)
|
||||||
|
- Check if MongoDB is listening on port 27017: `netstat -an | grep 27017`
|
||||||
|
|
||||||
|
3. **Redis Connection Error** (Optional):
|
||||||
|
- If you don't have Redis installed, you can comment out the REDIS_URL in your `.env` file
|
||||||
|
- The application will work without Redis, but caching and background jobs won't be available
|
||||||
|
|
||||||
|
## 📚 API Endpoints
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
- `POST /api/auth/register` - User registration
|
||||||
|
- `POST /api/auth/login` - User login
|
||||||
|
- `POST /api/auth/refresh` - Refresh JWT token
|
||||||
|
- `POST /api/auth/logout` - User logout
|
||||||
|
- `GET /api/auth/me` - Get current user profile
|
||||||
|
|
||||||
|
### Users
|
||||||
|
- `GET /api/users/profile` - Get user profile
|
||||||
|
- `PUT /api/users/profile` - Update user profile
|
||||||
|
- `GET /api/users/addresses` - Get user addresses
|
||||||
|
- `POST /api/users/addresses` - Add address
|
||||||
|
- `GET /api/users/orders` - Get user orders
|
||||||
|
- `GET /api/users/wishlist` - Get user wishlist
|
||||||
|
|
||||||
|
### Products
|
||||||
|
- `GET /api/products` - Get all products (with filters)
|
||||||
|
- `GET /api/products/:slug` - Get single product
|
||||||
|
- `POST /api/products` - Create product (Admin)
|
||||||
|
- `PUT /api/products/:id` - Update product (Admin)
|
||||||
|
- `DELETE /api/products/:id` - Delete product (Admin)
|
||||||
|
|
||||||
|
### Orders
|
||||||
|
- `POST /api/orders` - Create new order
|
||||||
|
- `GET /api/orders` - Get user orders
|
||||||
|
- `GET /api/orders/:id` - Get single order
|
||||||
|
- `PUT /api/orders/:id/cancel` - Cancel order
|
||||||
|
|
||||||
|
### Wardrobe
|
||||||
|
- `GET /api/wardrobe` - Get user's wardrobe
|
||||||
|
- `POST /api/wardrobe/items` - Add item to wardrobe
|
||||||
|
- `PUT /api/wardrobe/items/:id` - Update wardrobe item
|
||||||
|
- `DELETE /api/wardrobe/items/:id` - Remove wardrobe item
|
||||||
|
- `GET /api/wardrobe/recommendations` - Get outfit recommendations
|
||||||
|
|
||||||
|
### Admin
|
||||||
|
- `GET /api/admin/dashboard` - Dashboard statistics
|
||||||
|
- `GET /api/admin/users` - Manage users
|
||||||
|
- `GET /api/admin/orders` - Manage orders
|
||||||
|
- `GET /api/admin/products` - Manage products
|
||||||
|
- `GET /api/admin/categories` - Manage categories
|
||||||
|
- `GET /api/admin/coupons` - Manage coupons
|
||||||
|
|
||||||
|
## 🔐 Authentication
|
||||||
|
|
||||||
|
The API uses JWT-based authentication. Include the token in the Authorization header:
|
||||||
|
|
||||||
|
```
|
||||||
|
Authorization: Bearer <your-jwt-token>
|
||||||
|
```
|
||||||
|
|
||||||
|
### User Roles
|
||||||
|
|
||||||
|
- `CUSTOMER` - Default role for registered users
|
||||||
|
- `ADMIN` - Full access to all endpoints
|
||||||
|
- `MODERATOR` - Limited admin access
|
||||||
|
- `SELLER` - Can manage their own products
|
||||||
|
|
||||||
|
## 🗄️ Database Schema
|
||||||
|
|
||||||
|
### PostgreSQL (Prisma)
|
||||||
|
- Users & Authentication
|
||||||
|
- Orders & Transactions
|
||||||
|
- Addresses
|
||||||
|
- Reviews & Ratings
|
||||||
|
- Wishlist & Cart
|
||||||
|
- Categories & Coupons
|
||||||
|
- System Configuration
|
||||||
|
|
||||||
|
### MongoDB (Mongoose)
|
||||||
|
- Products (flexible schema for variants, images, AI tags)
|
||||||
|
- Wardrobe (user clothing collections with AI analysis)
|
||||||
|
|
||||||
|
## 🚀 Deployment
|
||||||
|
|
||||||
|
### Environment Variables for Production
|
||||||
|
|
||||||
|
Ensure all production environment variables are set:
|
||||||
|
- Database URLs (production databases)
|
||||||
|
- JWT secrets (strong, unique values)
|
||||||
|
- AWS credentials
|
||||||
|
- Email/SMS service credentials
|
||||||
|
|
||||||
|
### Deployment Options
|
||||||
|
|
||||||
|
You can deploy this application to various platforms:
|
||||||
|
- **Heroku**: Use the Procfile and configure environment variables
|
||||||
|
- **AWS EC2**: Set up Node.js environment and configure databases
|
||||||
|
- **DigitalOcean App Platform**: Connect your repository and configure environment variables
|
||||||
|
- **Railway**: Connect your GitHub repository for automatic deployments
|
||||||
|
- **Vercel**: For serverless deployment (with some modifications)
|
||||||
|
|
||||||
|
## 🧪 Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run tests
|
||||||
|
npm test
|
||||||
|
|
||||||
|
# Run tests in watch mode
|
||||||
|
npm run test:watch
|
||||||
|
|
||||||
|
# Generate coverage report
|
||||||
|
npm run test:coverage
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📝 Scripts
|
||||||
|
|
||||||
|
- `npm start` - Start production server
|
||||||
|
- `npm run dev` - Start development server with nodemon
|
||||||
|
- `npm run build` - Generate Prisma client
|
||||||
|
- `npm run lint` - Run ESLint
|
||||||
|
- `npm run format` - Format code with Prettier
|
||||||
|
- `npm run db:studio` - Open Prisma Studio
|
||||||
|
- `npm run db:migrate` - Run database migrations
|
||||||
|
|
||||||
|
## 🔄 Background Jobs
|
||||||
|
|
||||||
|
The application uses BullMQ with Redis for processing:
|
||||||
|
- AI image tagging for wardrobe items
|
||||||
|
- Email notifications
|
||||||
|
- Image processing and optimization
|
||||||
|
- Recommendation engine updates
|
||||||
|
|
||||||
|
## 📊 Monitoring & Logging
|
||||||
|
|
||||||
|
- Health check endpoint: `GET /health`
|
||||||
|
- Structured logging with Morgan
|
||||||
|
- Error tracking and reporting
|
||||||
|
- Database connection monitoring
|
||||||
|
|
||||||
|
## 🤝 Contributing
|
||||||
|
|
||||||
|
1. Fork the repository
|
||||||
|
2. Create a feature branch: `git checkout -b feature/amazing-feature`
|
||||||
|
3. Commit changes: `git commit -m 'Add amazing feature'`
|
||||||
|
4. Push to branch: `git push origin feature/amazing-feature`
|
||||||
|
5. Open a Pull Request
|
||||||
|
|
||||||
|
## 📄 License
|
||||||
|
|
||||||
|
This project is licensed under the ISC License.
|
||||||
|
|
||||||
|
## 🆘 Support
|
||||||
|
|
||||||
|
For support and questions:
|
||||||
|
- Create an issue in the repository
|
||||||
|
- Check the API documentation at `/api/docs`
|
||||||
|
- Review the health check at `/health`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Built with ❤️ for Vaishnavi Creation**
|
||||||
9431
package-lock.json
generated
Normal file
9431
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
69
package.json
Normal file
69
package.json
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
{
|
||||||
|
"name": "vaishnavi-backend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Vaishnavi Creation - E-commerce Backend API",
|
||||||
|
"main": "src/server.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node src/server.js",
|
||||||
|
"dev": "nodemon src/server.js",
|
||||||
|
"build": "npm run db:generate",
|
||||||
|
"test": "jest",
|
||||||
|
"test:watch": "jest --watch",
|
||||||
|
"test:coverage": "jest --coverage",
|
||||||
|
"lint": "eslint src/",
|
||||||
|
"lint:fix": "eslint src/ --fix",
|
||||||
|
"format": "prettier --write src/",
|
||||||
|
"format:check": "prettier --check src/",
|
||||||
|
"db:generate": "prisma generate",
|
||||||
|
"db:push": "prisma db push",
|
||||||
|
"db:migrate": "prisma migrate dev",
|
||||||
|
"db:deploy": "prisma migrate deploy",
|
||||||
|
"db:studio": "prisma studio",
|
||||||
|
"db:seed": "node prisma/seed.js",
|
||||||
|
"setup": "node setup-local.js",
|
||||||
|
"test:connections": "node test-connections.js"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"e-commerce",
|
||||||
|
"fashion",
|
||||||
|
"api",
|
||||||
|
"nodejs",
|
||||||
|
"express",
|
||||||
|
"postgresql",
|
||||||
|
"mongodb"
|
||||||
|
],
|
||||||
|
"author": "Vaishnavi Creation",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@prisma/client": "^5.22.0",
|
||||||
|
"aws-sdk": "^2.1693.0",
|
||||||
|
"axios": "^1.13.5",
|
||||||
|
"bcrypt": "^5.1.1",
|
||||||
|
"bullmq": "^5.25.0",
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.4.7",
|
||||||
|
"ejs": "^3.1.10",
|
||||||
|
"express": "^4.21.2",
|
||||||
|
"helmet": "^8.0.0",
|
||||||
|
"ioredis": "^5.4.1",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"mongoose": "^8.9.2",
|
||||||
|
"morgan": "^1.10.0",
|
||||||
|
"multer": "^1.4.5-lts.1",
|
||||||
|
"multer-s3": "^3.0.1",
|
||||||
|
"nodemailer": "^7.0.11",
|
||||||
|
"prisma": "^5.22.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"eslint": "^9.17.0",
|
||||||
|
"jest": "^29.7.0",
|
||||||
|
"nodemon": "^3.1.9",
|
||||||
|
"prettier": "^3.4.2",
|
||||||
|
"supertest": "^7.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0",
|
||||||
|
"npm": ">=8.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
384
prisma/schema.prisma
Normal file
384
prisma/schema.prisma
Normal file
@@ -0,0 +1,384 @@
|
|||||||
|
// This is your Prisma schema file,
|
||||||
|
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||||
|
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
// User Management & Authentication
|
||||||
|
model User {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
email String @unique
|
||||||
|
username String? @unique
|
||||||
|
firstName String?
|
||||||
|
lastName String?
|
||||||
|
phone String?
|
||||||
|
avatar String?
|
||||||
|
|
||||||
|
// Authentication
|
||||||
|
passwordHash String
|
||||||
|
isVerified Boolean @default(false)
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
|
||||||
|
// Roles & Permissions
|
||||||
|
role UserRole @default(CUSTOMER)
|
||||||
|
|
||||||
|
// Timestamps
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
lastLoginAt DateTime?
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
addresses Address[]
|
||||||
|
orders Order[]
|
||||||
|
reviews Review[]
|
||||||
|
wishlist WishlistItem[]
|
||||||
|
cart CartItem[]
|
||||||
|
|
||||||
|
orderStatusChanges OrderStatusHistory[]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@@map("users")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum UserRole {
|
||||||
|
CUSTOMER
|
||||||
|
ADMIN
|
||||||
|
SUPER_ADMIN
|
||||||
|
EDITOR
|
||||||
|
MODERATOR
|
||||||
|
SELLER
|
||||||
|
}
|
||||||
|
|
||||||
|
// Address Management
|
||||||
|
model Address {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
type AddressType @default(SHIPPING)
|
||||||
|
isDefault Boolean @default(false)
|
||||||
|
|
||||||
|
// Address Details
|
||||||
|
firstName String
|
||||||
|
lastName String
|
||||||
|
company String?
|
||||||
|
addressLine1 String
|
||||||
|
addressLine2 String?
|
||||||
|
city String
|
||||||
|
state String
|
||||||
|
postalCode String
|
||||||
|
country String
|
||||||
|
phone String?
|
||||||
|
|
||||||
|
// Timestamps
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
orders Order[]
|
||||||
|
|
||||||
|
@@map("addresses")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AddressType {
|
||||||
|
SHIPPING
|
||||||
|
BILLING
|
||||||
|
}
|
||||||
|
|
||||||
|
// Order Management
|
||||||
|
model Order {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
orderNumber String @unique
|
||||||
|
userId String
|
||||||
|
status OrderStatus @default(PENDING)
|
||||||
|
|
||||||
|
// Pricing
|
||||||
|
subtotal Decimal @db.Decimal(10, 2)
|
||||||
|
taxAmount Decimal @db.Decimal(10, 2)
|
||||||
|
shippingAmount Decimal @db.Decimal(10, 2)
|
||||||
|
discountAmount Decimal @db.Decimal(10, 2) @default(0)
|
||||||
|
totalAmount Decimal @db.Decimal(10, 2)
|
||||||
|
|
||||||
|
// Payment
|
||||||
|
paymentStatus PaymentStatus @default(PENDING)
|
||||||
|
paymentMethod String?
|
||||||
|
paymentId String?
|
||||||
|
|
||||||
|
// Shipping
|
||||||
|
shippingAddressId String
|
||||||
|
trackingNumber String?
|
||||||
|
|
||||||
|
// Timestamps
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
shippedAt DateTime?
|
||||||
|
deliveredAt DateTime?
|
||||||
|
|
||||||
|
// ✅ Add this field
|
||||||
|
returnRequestedAt DateTime?
|
||||||
|
returnStatus ReturnStatus @default(NONE)
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
address Address @relation(fields: [shippingAddressId], references: [id])
|
||||||
|
items OrderItem[]
|
||||||
|
|
||||||
|
statusHistory OrderStatusHistory[]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@@map("orders")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
model OrderStatusHistory {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
orderId String
|
||||||
|
|
||||||
|
fromStatus OrderStatus?
|
||||||
|
toStatus OrderStatus
|
||||||
|
changedBy String?
|
||||||
|
|
||||||
|
trackingNumber String?
|
||||||
|
notes String?
|
||||||
|
ipAddress String?
|
||||||
|
userAgent String?
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
|
||||||
|
admin User? @relation(fields: [changedBy], references: [id])
|
||||||
|
|
||||||
|
@@index([orderId])
|
||||||
|
@@index([createdAt])
|
||||||
|
@@map("order_status_history")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
enum OrderStatus {
|
||||||
|
PENDING
|
||||||
|
CONFIRMED
|
||||||
|
PROCESSING
|
||||||
|
SHIPPED
|
||||||
|
DELIVERED
|
||||||
|
CANCELLED
|
||||||
|
REFUNDED
|
||||||
|
RETURN_REQUESTED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum PaymentStatus {
|
||||||
|
PENDING
|
||||||
|
PAID
|
||||||
|
FAILED
|
||||||
|
REFUNDED
|
||||||
|
PARTIALLY_REFUNDED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ReturnStatus {
|
||||||
|
NONE
|
||||||
|
REQUESTED
|
||||||
|
APPROVED
|
||||||
|
REJECTED
|
||||||
|
COMPLETED
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Order Items
|
||||||
|
model OrderItem {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
orderId String
|
||||||
|
productId String // Reference to MongoDB product
|
||||||
|
|
||||||
|
// Product Details (snapshot at time of order)
|
||||||
|
productName String
|
||||||
|
productSku String
|
||||||
|
price Decimal @db.Decimal(10, 2)
|
||||||
|
quantity Int
|
||||||
|
|
||||||
|
// Timestamps
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@map("order_items")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reviews & Ratings
|
||||||
|
model Review {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
productId String // Reference to MongoDB product
|
||||||
|
orderId String? // Optional reference to order
|
||||||
|
|
||||||
|
rating Int // 1-5 stars
|
||||||
|
title String?
|
||||||
|
comment String?
|
||||||
|
isVerified Boolean @default(false)
|
||||||
|
|
||||||
|
// Timestamps
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
|
||||||
|
@@unique([userId, productId])
|
||||||
|
@@map("reviews")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wishlist Management
|
||||||
|
model WishlistItem {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
productId String // Reference to MongoDB product
|
||||||
|
|
||||||
|
// Timestamps
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([userId, productId])
|
||||||
|
@@map("wishlist_items")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shopping Cart
|
||||||
|
model CartItem {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
productId String // Reference to MongoDB product
|
||||||
|
quantity Int @default(1)
|
||||||
|
|
||||||
|
// Timestamps
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([userId, productId])
|
||||||
|
@@map("cart_items")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Categories (Reference data)
|
||||||
|
// Categories (Reference data)
|
||||||
|
model Category {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String // Removed @unique - same name can exist under different parents
|
||||||
|
slug String // Removed @unique - will use composite unique instead
|
||||||
|
description String?
|
||||||
|
image String?
|
||||||
|
parentId String?
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
sequence Int @default(0)
|
||||||
|
|
||||||
|
// SEO
|
||||||
|
metaTitle String?
|
||||||
|
metaDescription String?
|
||||||
|
|
||||||
|
// Timestamps
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
parent Category? @relation("CategoryHierarchy", fields: [parentId], references: [id])
|
||||||
|
children Category[] @relation("CategoryHierarchy")
|
||||||
|
|
||||||
|
// Composite unique constraint: same slug allowed if different parent
|
||||||
|
@@unique([slug, parentId], name: "unique_slug_per_parent")
|
||||||
|
|
||||||
|
@@map("categories")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Coupons & Discounts
|
||||||
|
model Coupon {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
code String @unique
|
||||||
|
description String?
|
||||||
|
type CouponType
|
||||||
|
value Decimal @db.Decimal(10, 2)
|
||||||
|
|
||||||
|
// Conditions
|
||||||
|
minOrderAmount Decimal? @db.Decimal(10, 2)
|
||||||
|
maxUses Int?
|
||||||
|
usedCount Int @default(0)
|
||||||
|
|
||||||
|
// Validity
|
||||||
|
validFrom DateTime
|
||||||
|
validUntil DateTime
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
|
||||||
|
// Timestamps
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@map("coupons")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CouponType {
|
||||||
|
PERCENTAGE
|
||||||
|
FIXED_AMOUNT
|
||||||
|
FREE_SHIPPING
|
||||||
|
}
|
||||||
|
|
||||||
|
// System Configuration
|
||||||
|
model SystemConfig {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
key String @unique
|
||||||
|
value String
|
||||||
|
type ConfigType @default(STRING)
|
||||||
|
|
||||||
|
// Timestamps
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@map("system_config")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ConfigType {
|
||||||
|
STRING
|
||||||
|
NUMBER
|
||||||
|
BOOLEAN
|
||||||
|
JSON
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
model InventoryLog {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
|
||||||
|
productId String
|
||||||
|
productName String
|
||||||
|
|
||||||
|
type InventoryLogType
|
||||||
|
quantityChange Int
|
||||||
|
previousStock Int
|
||||||
|
newStock Int
|
||||||
|
|
||||||
|
orderId String?
|
||||||
|
adjustedBy String?
|
||||||
|
notes String?
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([productId])
|
||||||
|
@@index([orderId])
|
||||||
|
@@index([type])
|
||||||
|
@@map("inventory_logs")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum InventoryLogType {
|
||||||
|
SOLD
|
||||||
|
RESTOCK
|
||||||
|
ADJUSTMENT
|
||||||
|
RETURN
|
||||||
|
DAMAGED
|
||||||
|
}
|
||||||
174
prisma/seed.js
Normal file
174
prisma/seed.js
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
const { PrismaClient } = require('@prisma/client');
|
||||||
|
const bcrypt = require('bcrypt');
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('🌱 Starting database seed...');
|
||||||
|
|
||||||
|
// Create admin user
|
||||||
|
const adminPasswordHash = await bcrypt.hash('admin123', 12);
|
||||||
|
const admin = await prisma.user.upsert({
|
||||||
|
where: { email: 'admin@vaishnavi.com' },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
email: 'admin@vaishnavi.com',
|
||||||
|
passwordHash: adminPasswordHash,
|
||||||
|
firstName: 'Admin',
|
||||||
|
lastName: 'User',
|
||||||
|
username: 'admin',
|
||||||
|
role: 'ADMIN',
|
||||||
|
isVerified: true,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create test customer
|
||||||
|
const customerPasswordHash = await bcrypt.hash('customer123', 12);
|
||||||
|
const customer = await prisma.user.upsert({
|
||||||
|
where: { email: 'customer@example.com' },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
email: 'customer@example.com',
|
||||||
|
passwordHash: customerPasswordHash,
|
||||||
|
firstName: 'John',
|
||||||
|
lastName: 'Doe',
|
||||||
|
username: 'johndoe',
|
||||||
|
role: 'CUSTOMER',
|
||||||
|
isVerified: true,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create categories
|
||||||
|
const categories = [
|
||||||
|
{
|
||||||
|
name: 'Women\'s Clothing',
|
||||||
|
slug: 'womens-clothing',
|
||||||
|
description: 'Beautiful women\'s fashion and apparel',
|
||||||
|
metaTitle: 'Women\'s Clothing - Vaishnavi Creation',
|
||||||
|
metaDescription: 'Discover our collection of women\'s clothing and fashion items.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Men\'s Clothing',
|
||||||
|
slug: 'mens-clothing',
|
||||||
|
description: 'Stylish men\'s fashion and apparel',
|
||||||
|
metaTitle: 'Men\'s Clothing - Vaishnavi Creation',
|
||||||
|
metaDescription: 'Explore our range of men\'s clothing and fashion items.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Accessories',
|
||||||
|
slug: 'accessories',
|
||||||
|
description: 'Fashion accessories and jewelry',
|
||||||
|
metaTitle: 'Accessories - Vaishnavi Creation',
|
||||||
|
metaDescription: 'Complete your look with our fashion accessories.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Shoes',
|
||||||
|
slug: 'shoes',
|
||||||
|
description: 'Comfortable and stylish footwear',
|
||||||
|
metaTitle: 'Shoes - Vaishnavi Creation',
|
||||||
|
metaDescription: 'Find the perfect pair of shoes for any occasion.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const categoryData of categories) {
|
||||||
|
await prisma.category.upsert({
|
||||||
|
where: { slug: categoryData.slug },
|
||||||
|
update: {},
|
||||||
|
create: categoryData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create sample coupons
|
||||||
|
const coupons = [
|
||||||
|
{
|
||||||
|
code: 'WELCOME10',
|
||||||
|
description: '10% off for new customers',
|
||||||
|
type: 'PERCENTAGE',
|
||||||
|
value: 10,
|
||||||
|
minOrderAmount: 50,
|
||||||
|
maxUses: 1000,
|
||||||
|
validFrom: new Date(),
|
||||||
|
validUntil: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'FREESHIP',
|
||||||
|
description: 'Free shipping on orders over $100',
|
||||||
|
type: 'FREE_SHIPPING',
|
||||||
|
value: 0,
|
||||||
|
minOrderAmount: 100,
|
||||||
|
maxUses: null,
|
||||||
|
validFrom: new Date(),
|
||||||
|
validUntil: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000), // 90 days
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const couponData of coupons) {
|
||||||
|
await prisma.coupon.upsert({
|
||||||
|
where: { code: couponData.code },
|
||||||
|
update: {},
|
||||||
|
create: couponData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create system configuration
|
||||||
|
const systemConfigs = [
|
||||||
|
{
|
||||||
|
key: 'site_name',
|
||||||
|
value: 'Vaishnavi Creation',
|
||||||
|
type: 'STRING',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'site_description',
|
||||||
|
value: 'Your premier destination for fashion and style',
|
||||||
|
type: 'STRING',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'currency',
|
||||||
|
value: 'USD',
|
||||||
|
type: 'STRING',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'tax_rate',
|
||||||
|
value: '10',
|
||||||
|
type: 'NUMBER',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'free_shipping_threshold',
|
||||||
|
value: '100',
|
||||||
|
type: 'NUMBER',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'maintenance_mode',
|
||||||
|
value: 'false',
|
||||||
|
type: 'BOOLEAN',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const configData of systemConfigs) {
|
||||||
|
await prisma.systemConfig.upsert({
|
||||||
|
where: { key: configData.key },
|
||||||
|
update: {},
|
||||||
|
create: configData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✅ Database seeded successfully!');
|
||||||
|
console.log(`👤 Admin user created: admin@vaishnavi.com / admin123`);
|
||||||
|
console.log(`👤 Customer user created: customer@example.com / customer123`);
|
||||||
|
console.log(`📦 ${categories.length} categories created`);
|
||||||
|
console.log(`🎫 ${coupons.length} coupons created`);
|
||||||
|
console.log(`⚙️ ${systemConfigs.length} system configs created`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error('❌ Seed failed:', e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
101
setup-local.js
Normal file
101
setup-local.js
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Local Development Setup Script
|
||||||
|
*
|
||||||
|
* This script helps set up the local development environment
|
||||||
|
* by creating the PostgreSQL database and running initial setup.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { execSync } = require('child_process');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
console.log('🚀 Setting up Vaishnavi Creation Backend for local development...\n');
|
||||||
|
|
||||||
|
// Check if .env file exists
|
||||||
|
const envPath = path.join(__dirname, '.env');
|
||||||
|
const envExamplePath = path.join(__dirname, '.env.example');
|
||||||
|
|
||||||
|
if (!fs.existsSync(envPath)) {
|
||||||
|
if (fs.existsSync(envExamplePath)) {
|
||||||
|
console.log('📝 Creating .env file from .env.example...');
|
||||||
|
fs.copyFileSync(envExamplePath, envPath);
|
||||||
|
console.log('✅ .env file created! Please update it with your database credentials.\n');
|
||||||
|
} else {
|
||||||
|
console.log('❌ .env.example file not found. Please create a .env file manually.\n');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Node.js version
|
||||||
|
const nodeVersion = process.version;
|
||||||
|
const majorVersion = parseInt(nodeVersion.slice(1).split('.')[0]);
|
||||||
|
|
||||||
|
if (majorVersion < 18) {
|
||||||
|
console.log('❌ Node.js 18+ is required. Current version:', nodeVersion);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✅ Node.js version check passed:', nodeVersion);
|
||||||
|
|
||||||
|
// Install dependencies
|
||||||
|
console.log('\n📦 Installing dependencies...');
|
||||||
|
try {
|
||||||
|
execSync('npm install', { stdio: 'inherit' });
|
||||||
|
console.log('✅ Dependencies installed successfully!');
|
||||||
|
} catch (error) {
|
||||||
|
console.log('❌ Failed to install dependencies:', error.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate Prisma client
|
||||||
|
console.log('\n🔧 Generating Prisma client...');
|
||||||
|
try {
|
||||||
|
execSync('npx prisma generate', { stdio: 'inherit' });
|
||||||
|
console.log('✅ Prisma client generated successfully!');
|
||||||
|
} catch (error) {
|
||||||
|
console.log('❌ Failed to generate Prisma client:', error.message);
|
||||||
|
console.log('💡 Make sure your PostgreSQL database is running and the DATABASE_URL in .env is correct.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test database connection and run migrations
|
||||||
|
console.log('\n🗄️ Setting up database...');
|
||||||
|
try {
|
||||||
|
execSync('npx prisma db push', { stdio: 'inherit' });
|
||||||
|
console.log('✅ Database schema created successfully!');
|
||||||
|
} catch (error) {
|
||||||
|
console.log('❌ Failed to create database schema:', error.message);
|
||||||
|
console.log('💡 Please check:');
|
||||||
|
console.log(' - PostgreSQL is running');
|
||||||
|
console.log(' - Database "vaishnavi_db" exists');
|
||||||
|
console.log(' - DATABASE_URL in .env is correct');
|
||||||
|
console.log(' - User has proper permissions');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed database
|
||||||
|
console.log('\n🌱 Seeding database...');
|
||||||
|
try {
|
||||||
|
execSync('npm run db:seed', { stdio: 'inherit' });
|
||||||
|
console.log('✅ Database seeded successfully!');
|
||||||
|
} catch (error) {
|
||||||
|
console.log('⚠️ Failed to seed database:', error.message);
|
||||||
|
console.log('💡 You can run "npm run db:seed" manually later.');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n🎉 Setup completed successfully!');
|
||||||
|
console.log('\n📋 Next steps:');
|
||||||
|
console.log('1. Make sure MongoDB is running on port 27017');
|
||||||
|
console.log('2. Update your .env file with correct database credentials');
|
||||||
|
console.log('3. Run "npm run dev" to start the development server');
|
||||||
|
console.log('\n🔗 Useful commands:');
|
||||||
|
console.log('- Start dev server: npm run dev');
|
||||||
|
console.log('- View database: npm run db:studio');
|
||||||
|
console.log('- Run tests: npm test');
|
||||||
|
console.log('- Lint code: npm run lint');
|
||||||
|
console.log('\n📚 API will be available at: http://localhost:3000');
|
||||||
|
console.log('🏥 Health check: http://localhost:3000/health');
|
||||||
|
console.log('\n👤 Default admin user: admin@vaishnavi.com / admin123');
|
||||||
|
console.log('👤 Default customer: customer@example.com / customer123');
|
||||||
57
src/config/database.js
Normal file
57
src/config/database.js
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
const { PrismaClient } = require('@prisma/client');
|
||||||
|
const mongoose = require('mongoose');
|
||||||
|
|
||||||
|
// PostgreSQL Connection (Prisma)
|
||||||
|
const prisma = new PrismaClient({
|
||||||
|
log: process.env.NODE_ENV === 'development' ? ['query', 'info', 'warn', 'error'] : ['error'],
|
||||||
|
});
|
||||||
|
|
||||||
|
// MongoDB Connection (Mongoose)
|
||||||
|
const connectMongoDB = async () => {
|
||||||
|
try {
|
||||||
|
await mongoose.connect(process.env.MONGODB_URI);
|
||||||
|
console.log('✅ MongoDB connected successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ MongoDB connection error:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// PostgreSQL Connection (Prisma)
|
||||||
|
const connectPostgreSQL = async () => {
|
||||||
|
try {
|
||||||
|
await prisma.$connect();
|
||||||
|
console.log('✅ PostgreSQL connected successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ PostgreSQL connection error:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize all database connections
|
||||||
|
const initializeDatabases = async () => {
|
||||||
|
await Promise.all([
|
||||||
|
connectPostgreSQL(),
|
||||||
|
connectMongoDB(),
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Graceful shutdown
|
||||||
|
const closeDatabaseConnections = async () => {
|
||||||
|
try {
|
||||||
|
await Promise.all([
|
||||||
|
prisma.$disconnect(),
|
||||||
|
mongoose.connection.close(),
|
||||||
|
]);
|
||||||
|
console.log('✅ Database connections closed');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error closing database connections:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
prisma,
|
||||||
|
mongoose,
|
||||||
|
initializeDatabases,
|
||||||
|
closeDatabaseConnections,
|
||||||
|
};
|
||||||
5
src/config/returnPolicy.js
Normal file
5
src/config/returnPolicy.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
// backend/config/returnPolicy.js
|
||||||
|
module.exports = {
|
||||||
|
RETURN_WINDOW_DAYS: 7,
|
||||||
|
ALLOWED_STATUSES: ['DELIVERED'],
|
||||||
|
};
|
||||||
16
src/config/s3.js
Normal file
16
src/config/s3.js
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
// config/s3.js
|
||||||
|
// const { S3Client } from "@aws-sdk/client-s3";
|
||||||
|
const { S3Client } = require("@aws-sdk/client-s3");
|
||||||
|
|
||||||
|
const s3 = new S3Client({
|
||||||
|
endpoint: "https://s3.sahasrarameta.tech",
|
||||||
|
region: process.env.AWS_REGION,
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
|
||||||
|
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
|
||||||
|
},
|
||||||
|
forcePathStyle: true, // IMPORTANT for MinIO
|
||||||
|
});
|
||||||
|
|
||||||
|
// export default s3;
|
||||||
|
module.exports = s3;
|
||||||
375
src/controllers/admin/categoryController.js
Normal file
375
src/controllers/admin/categoryController.js
Normal file
@@ -0,0 +1,375 @@
|
|||||||
|
const { prisma } = require('../../config/database');
|
||||||
|
const uploadToS3 = require('../../utils/uploadToS3');
|
||||||
|
|
||||||
|
exports.getAllCategories = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const categories = await prisma.category.findMany({
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
});
|
||||||
|
// res.json({ success: true, data: categories });
|
||||||
|
res.status(200).json({
|
||||||
|
// statusCode: 200,
|
||||||
|
status: true,
|
||||||
|
message: 'Categories fetched successfully',
|
||||||
|
data: categories,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// exports.createCategory = async (req, res, next) => {
|
||||||
|
// try {
|
||||||
|
// const { name, description, image, parentId, metaTitle, metaDescription } =
|
||||||
|
// req.body;
|
||||||
|
|
||||||
|
// // Generate slug from name
|
||||||
|
// let slug = name
|
||||||
|
// .toLowerCase()
|
||||||
|
// .replace(/[^a-z0-9]+/g, '-')
|
||||||
|
// .replace(/(^-|-$)/g, '');
|
||||||
|
|
||||||
|
// // Check if slug already exists with the same parent
|
||||||
|
// const existing = await prisma.category.findFirst({
|
||||||
|
// where: {
|
||||||
|
// slug,
|
||||||
|
// parentId: parentId || null, // Handle both subcategories and root categories
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
|
||||||
|
// if (existing) {
|
||||||
|
// // If exists under same parent, append timestamp to make it unique
|
||||||
|
// slug = `${slug}-${Date.now()}`;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// const category = await prisma.category.create({
|
||||||
|
// data: {
|
||||||
|
// name,
|
||||||
|
// slug,
|
||||||
|
// description,
|
||||||
|
// image,
|
||||||
|
// parentId,
|
||||||
|
// metaTitle,
|
||||||
|
// metaDescription,
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
|
||||||
|
// res.status(201).json({
|
||||||
|
// statusCode: 201,
|
||||||
|
// status: true,
|
||||||
|
// message: 'Category created successfully',
|
||||||
|
// data: category,
|
||||||
|
// });
|
||||||
|
// } catch (error) {
|
||||||
|
// // Handle Prisma duplicate error explicitly
|
||||||
|
// if (error.code === 'P2002') {
|
||||||
|
// return res.status(400).json({
|
||||||
|
// statusCode: 400,
|
||||||
|
// status: false,
|
||||||
|
// message: 'Duplicate field value entered',
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
// next(error);
|
||||||
|
// }
|
||||||
|
// };
|
||||||
|
|
||||||
|
// exports.updateCategory = async (req, res, next) => {
|
||||||
|
// try {
|
||||||
|
// const { id } = req.params;
|
||||||
|
// const { name, description, image, parentId, metaTitle, metaDescription } =
|
||||||
|
// req.body;
|
||||||
|
|
||||||
|
// let slug;
|
||||||
|
// if (name) {
|
||||||
|
// slug = name
|
||||||
|
// .toLowerCase()
|
||||||
|
// .replace(/[^a-z0-9]+/g, '-')
|
||||||
|
// .replace(/(^-|-$)/g, '');
|
||||||
|
// }
|
||||||
|
|
||||||
|
// const category = await prisma.category.update({
|
||||||
|
// where: { id },
|
||||||
|
// data: {
|
||||||
|
// name,
|
||||||
|
// slug,
|
||||||
|
// description,
|
||||||
|
// image,
|
||||||
|
// parentId,
|
||||||
|
// metaTitle,
|
||||||
|
// metaDescription,
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
|
||||||
|
// res.json({
|
||||||
|
// status: true,
|
||||||
|
// message: 'Category updated successfully',
|
||||||
|
// data: category,
|
||||||
|
// });
|
||||||
|
// } catch (error) {
|
||||||
|
// next(error);
|
||||||
|
// }
|
||||||
|
// };
|
||||||
|
|
||||||
|
exports.createCategory = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { name, description, parentId, metaTitle, metaDescription } =
|
||||||
|
req.body;
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
return res.status(400).json({
|
||||||
|
status: false,
|
||||||
|
message: 'Category name is required',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Generate slug
|
||||||
|
let slug = name
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/(^-|-$)/g, '');
|
||||||
|
|
||||||
|
// ✅ Check duplicate slug under same parent
|
||||||
|
const existing = await prisma.category.findFirst({
|
||||||
|
where: {
|
||||||
|
slug,
|
||||||
|
parentId: parentId || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
slug = `${slug}-${Date.now()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Upload image to S3 if provided
|
||||||
|
let imageUrl = null;
|
||||||
|
|
||||||
|
if (req.file) {
|
||||||
|
imageUrl = await uploadToS3(req.file, 'categories');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Create category
|
||||||
|
const category = await prisma.category.create({
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
slug,
|
||||||
|
description,
|
||||||
|
parentId: parentId || null,
|
||||||
|
metaTitle,
|
||||||
|
metaDescription,
|
||||||
|
image: imageUrl,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
statusCode: 201,
|
||||||
|
status: true,
|
||||||
|
message: 'Category created successfully',
|
||||||
|
data: category,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === 'P2002') {
|
||||||
|
return res.status(400).json({
|
||||||
|
statusCode: 400,
|
||||||
|
status: false,
|
||||||
|
message: 'Duplicate field value entered',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.updateCategory = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { name, description, parentId, metaTitle, metaDescription } =
|
||||||
|
req.body;
|
||||||
|
|
||||||
|
// Generate slug if name changed
|
||||||
|
let slug;
|
||||||
|
if (name) {
|
||||||
|
slug = name
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/(^-|-$)/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Upload new image if provided
|
||||||
|
let imageUrl;
|
||||||
|
if (req.file) {
|
||||||
|
imageUrl = await uploadToS3(req.file, 'categories');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update category
|
||||||
|
const category = await prisma.category.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...(name && { name }),
|
||||||
|
...(slug && { slug }),
|
||||||
|
...(description && { description }),
|
||||||
|
...(parentId !== undefined && { parentId }),
|
||||||
|
...(metaTitle && { metaTitle }),
|
||||||
|
...(metaDescription && { metaDescription }),
|
||||||
|
...(imageUrl && { image: imageUrl }), // 👈 only update if new image uploaded
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
status: true,
|
||||||
|
message: 'Category updated successfully',
|
||||||
|
data: category,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.deleteCategory = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
await prisma.category.delete({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
status: true,
|
||||||
|
message: 'Category deleted successfully',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.toggleCategoryStatus = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { isActive } = req.body;
|
||||||
|
|
||||||
|
// 1️⃣ Update parent category
|
||||||
|
const parentCategory = await prisma.category.update({
|
||||||
|
where: { id },
|
||||||
|
data: { isActive },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2️⃣ If parent is being deactivated, also deactivate all children recursively
|
||||||
|
if (!isActive) {
|
||||||
|
const deactivateChildren = async parentId => {
|
||||||
|
const children = await prisma.category.findMany({
|
||||||
|
where: { parentId },
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const child of children) {
|
||||||
|
await prisma.category.update({
|
||||||
|
where: { id: child.id },
|
||||||
|
data: { isActive: false },
|
||||||
|
});
|
||||||
|
// Recursive call for nested subcategories
|
||||||
|
await deactivateChildren(child.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await deactivateChildren(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
status: true,
|
||||||
|
message: `Category ${isActive ? 'activated' : 'deactivated'} successfully`,
|
||||||
|
data: parentCategory,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.reorderCategories = async (req, res) => {
|
||||||
|
const { orders } = req.body;
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
orders.map(item =>
|
||||||
|
prisma.category.update({
|
||||||
|
where: { id: item.id },
|
||||||
|
data: { sequence: item.sequence },
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
status: true,
|
||||||
|
message: 'Category order updated',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.getCategoryHierarchy = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
// 1. Fetch all categories
|
||||||
|
const categories = await prisma.category.findMany({
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Convert array to a lookup map
|
||||||
|
const lookup = {};
|
||||||
|
categories.forEach(cat => {
|
||||||
|
lookup[cat.id] = { ...cat, children: [] };
|
||||||
|
});
|
||||||
|
|
||||||
|
const hierarchy = [];
|
||||||
|
|
||||||
|
// 3. Build hierarchical structure
|
||||||
|
categories.forEach(cat => {
|
||||||
|
if (cat.parentId) {
|
||||||
|
lookup[cat.parentId].children.push(lookup[cat.id]);
|
||||||
|
} else {
|
||||||
|
hierarchy.push(lookup[cat.id]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
// statusCode: 200,
|
||||||
|
status: true,
|
||||||
|
message: 'Category hierarchy fetched successfully',
|
||||||
|
data: hierarchy,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.getCategoryById = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Category ID is required',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const category = await prisma.category.findUnique({
|
||||||
|
where: {
|
||||||
|
id: id, // ✅ PASS THE ACTUAL STRING VALUE
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!category) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Category not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Category details fetched successfully',
|
||||||
|
data: category,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get category by id error:', error);
|
||||||
|
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Error fetching category',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
463
src/controllers/admin/couponController.js
Normal file
463
src/controllers/admin/couponController.js
Normal file
@@ -0,0 +1,463 @@
|
|||||||
|
// const { prisma } = require('../../config/database');
|
||||||
|
|
||||||
|
// exports.getAllCoupons = async (req, res, next) => {
|
||||||
|
// try {
|
||||||
|
// const coupons = await prisma.coupon.findMany({
|
||||||
|
// orderBy: { createdAt: 'desc' },
|
||||||
|
// });
|
||||||
|
// // res.json({ success: true, data: coupons });
|
||||||
|
// res.status(200).json({
|
||||||
|
// // statusCode: 200,
|
||||||
|
// status: true,
|
||||||
|
// message: 'Coupons fetched successfully',
|
||||||
|
// data: coupons,
|
||||||
|
// });
|
||||||
|
// } catch (error) {
|
||||||
|
// next(error);
|
||||||
|
// }
|
||||||
|
// };
|
||||||
|
|
||||||
|
// exports.createCoupon = async (req, res, next) => {
|
||||||
|
// try {
|
||||||
|
// const {
|
||||||
|
// code,
|
||||||
|
// description,
|
||||||
|
// type,
|
||||||
|
// value,
|
||||||
|
// minOrderAmount,
|
||||||
|
// maxUses,
|
||||||
|
// validFrom,
|
||||||
|
// validUntil,
|
||||||
|
// } = req.body;
|
||||||
|
// const coupon = await prisma.coupon.create({
|
||||||
|
// data: {
|
||||||
|
// code,
|
||||||
|
// description,
|
||||||
|
// type,
|
||||||
|
// value,
|
||||||
|
// minOrderAmount,
|
||||||
|
// maxUses,
|
||||||
|
// validFrom: new Date(validFrom),
|
||||||
|
// validUntil: new Date(validUntil),
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
|
||||||
|
// res.status(201).json({
|
||||||
|
// // statusCode: 201,
|
||||||
|
// status: true,
|
||||||
|
// message: 'Coupon created successfully',
|
||||||
|
// data: coupon,
|
||||||
|
// });
|
||||||
|
// } catch (error) {
|
||||||
|
// next(error);
|
||||||
|
// }
|
||||||
|
// };
|
||||||
|
|
||||||
|
// controllers/admin/couponController.js - ENHANCED VERSION
|
||||||
|
|
||||||
|
const { prisma } = require('../../config/database');
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// ADMIN COUPON MANAGEMENT
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @desc Get all coupons (with filters)
|
||||||
|
* @route GET /api/admin/coupons
|
||||||
|
* @access Private/Admin
|
||||||
|
*/
|
||||||
|
exports.getAllCoupons = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { isActive, type, search, page = 1, limit = 20 } = req.query;
|
||||||
|
|
||||||
|
const skip = (parseInt(page) - 1) * parseInt(limit);
|
||||||
|
|
||||||
|
// Build filter
|
||||||
|
const where = {};
|
||||||
|
|
||||||
|
if (isActive !== undefined) {
|
||||||
|
where.isActive = isActive === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type) {
|
||||||
|
where.type = type;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
where.OR = [
|
||||||
|
{ code: { contains: search, mode: 'insensitive' } },
|
||||||
|
{ description: { contains: search, mode: 'insensitive' } },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const [coupons, total] = await Promise.all([
|
||||||
|
prisma.coupon.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
skip,
|
||||||
|
take: parseInt(limit),
|
||||||
|
}),
|
||||||
|
prisma.coupon.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Add usage statistics
|
||||||
|
const couponsWithStats = coupons.map(coupon => ({
|
||||||
|
...coupon,
|
||||||
|
usagePercentage: coupon.maxUses
|
||||||
|
? Math.round((coupon.usedCount / coupon.maxUses) * 100)
|
||||||
|
: 0,
|
||||||
|
isExpired: new Date() > new Date(coupon.validUntil),
|
||||||
|
isNotStarted: new Date() < new Date(coupon.validFrom),
|
||||||
|
remainingUses: coupon.maxUses ? coupon.maxUses - coupon.usedCount : null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
status: true,
|
||||||
|
message: 'Coupons fetched successfully',
|
||||||
|
data: {
|
||||||
|
coupons: couponsWithStats,
|
||||||
|
pagination: {
|
||||||
|
total,
|
||||||
|
page: parseInt(page),
|
||||||
|
limit: parseInt(limit),
|
||||||
|
pages: Math.ceil(total / parseInt(limit)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @desc Get single coupon by ID
|
||||||
|
* @route GET /api/admin/coupons/:id
|
||||||
|
* @access Private/Admin
|
||||||
|
*/
|
||||||
|
exports.getCouponById = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const coupon = await prisma.coupon.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!coupon) {
|
||||||
|
return res.status(404).json({
|
||||||
|
status: false,
|
||||||
|
message: 'Coupon not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get usage statistics
|
||||||
|
const usageStats = {
|
||||||
|
...coupon,
|
||||||
|
usagePercentage: coupon.maxUses
|
||||||
|
? Math.round((coupon.usedCount / coupon.maxUses) * 100)
|
||||||
|
: 0,
|
||||||
|
isExpired: new Date() > new Date(coupon.validUntil),
|
||||||
|
isNotStarted: new Date() < new Date(coupon.validFrom),
|
||||||
|
remainingUses: coupon.maxUses ? coupon.maxUses - coupon.usedCount : null,
|
||||||
|
};
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
status: true,
|
||||||
|
message: 'Coupon fetched successfully',
|
||||||
|
data: usageStats,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @desc Create new coupon
|
||||||
|
* @route POST /api/admin/coupons
|
||||||
|
* @access Private/Admin
|
||||||
|
*/
|
||||||
|
exports.createCoupon = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
code,
|
||||||
|
description,
|
||||||
|
type,
|
||||||
|
value,
|
||||||
|
minOrderAmount,
|
||||||
|
maxUses,
|
||||||
|
validFrom,
|
||||||
|
validUntil,
|
||||||
|
isActive = true,
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// Validate code uniqueness
|
||||||
|
const existingCoupon = await prisma.coupon.findUnique({
|
||||||
|
where: { code: code.toUpperCase() },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingCoupon) {
|
||||||
|
return res.status(400).json({
|
||||||
|
status: false,
|
||||||
|
message: 'Coupon code already exists',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate dates
|
||||||
|
const fromDate = new Date(validFrom);
|
||||||
|
const untilDate = new Date(validUntil);
|
||||||
|
|
||||||
|
if (fromDate >= untilDate) {
|
||||||
|
return res.status(400).json({
|
||||||
|
status: false,
|
||||||
|
message: 'Valid until date must be after valid from date',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate value based on type
|
||||||
|
if (type === 'PERCENTAGE' && (value < 0 || value > 100)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
status: false,
|
||||||
|
message: 'Percentage value must be between 0 and 100',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'FIXED_AMOUNT' && value < 0) {
|
||||||
|
return res.status(400).json({
|
||||||
|
status: false,
|
||||||
|
message: 'Fixed amount must be greater than 0',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const coupon = await prisma.coupon.create({
|
||||||
|
data: {
|
||||||
|
code: code.toUpperCase(),
|
||||||
|
description,
|
||||||
|
type,
|
||||||
|
value: parseFloat(value),
|
||||||
|
minOrderAmount: minOrderAmount ? parseFloat(minOrderAmount) : null,
|
||||||
|
maxUses: maxUses ? parseInt(maxUses) : null,
|
||||||
|
validFrom: fromDate,
|
||||||
|
validUntil: untilDate,
|
||||||
|
isActive,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
status: true,
|
||||||
|
message: 'Coupon created successfully',
|
||||||
|
data: coupon,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Create coupon error:', error);
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @desc Update coupon
|
||||||
|
* @route PUT /api/admin/coupons/:id
|
||||||
|
* @access Private/Admin
|
||||||
|
*/
|
||||||
|
exports.updateCoupon = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const {
|
||||||
|
code,
|
||||||
|
description,
|
||||||
|
type,
|
||||||
|
value,
|
||||||
|
minOrderAmount,
|
||||||
|
maxUses,
|
||||||
|
validFrom,
|
||||||
|
validUntil,
|
||||||
|
isActive,
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// Check if coupon exists
|
||||||
|
const existingCoupon = await prisma.coupon.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingCoupon) {
|
||||||
|
return res.status(404).json({
|
||||||
|
status: false,
|
||||||
|
message: 'Coupon not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// If code is being changed, check uniqueness
|
||||||
|
if (code && code.toUpperCase() !== existingCoupon.code) {
|
||||||
|
const duplicateCoupon = await prisma.coupon.findUnique({
|
||||||
|
where: { code: code.toUpperCase() },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (duplicateCoupon) {
|
||||||
|
return res.status(400).json({
|
||||||
|
status: false,
|
||||||
|
message: 'Coupon code already exists',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate dates if provided
|
||||||
|
const fromDate = validFrom ? new Date(validFrom) : existingCoupon.validFrom;
|
||||||
|
const untilDate = validUntil
|
||||||
|
? new Date(validUntil)
|
||||||
|
: existingCoupon.validUntil;
|
||||||
|
|
||||||
|
if (fromDate >= untilDate) {
|
||||||
|
return res.status(400).json({
|
||||||
|
status: false,
|
||||||
|
message: 'Valid until date must be after valid from date',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateData = {};
|
||||||
|
if (code) updateData.code = code.toUpperCase();
|
||||||
|
if (description !== undefined) updateData.description = description;
|
||||||
|
if (type) updateData.type = type;
|
||||||
|
if (value !== undefined) updateData.value = parseFloat(value);
|
||||||
|
if (minOrderAmount !== undefined)
|
||||||
|
updateData.minOrderAmount = minOrderAmount
|
||||||
|
? parseFloat(minOrderAmount)
|
||||||
|
: null;
|
||||||
|
if (maxUses !== undefined)
|
||||||
|
updateData.maxUses = maxUses ? parseInt(maxUses) : null;
|
||||||
|
if (validFrom) updateData.validFrom = fromDate;
|
||||||
|
if (validUntil) updateData.validUntil = untilDate;
|
||||||
|
if (isActive !== undefined) updateData.isActive = isActive;
|
||||||
|
|
||||||
|
const coupon = await prisma.coupon.update({
|
||||||
|
where: { id },
|
||||||
|
data: updateData,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
status: true,
|
||||||
|
message: 'Coupon updated successfully',
|
||||||
|
data: coupon,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @desc Delete coupon
|
||||||
|
* @route DELETE /api/admin/coupons/:id
|
||||||
|
* @access Private/Admin
|
||||||
|
*/
|
||||||
|
exports.deleteCoupon = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const coupon = await prisma.coupon.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!coupon) {
|
||||||
|
return res.status(404).json({
|
||||||
|
status: false,
|
||||||
|
message: 'Coupon not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if coupon has been used
|
||||||
|
if (coupon.usedCount > 0) {
|
||||||
|
return res.status(400).json({
|
||||||
|
status: false,
|
||||||
|
message:
|
||||||
|
'Cannot delete a coupon that has been used. Consider deactivating it instead.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.coupon.delete({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
status: true,
|
||||||
|
message: 'Coupon deleted successfully',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @desc Toggle coupon active status
|
||||||
|
* @route PATCH /api/admin/coupons/:id/toggle
|
||||||
|
* @access Private/Admin
|
||||||
|
*/
|
||||||
|
exports.toggleCouponStatus = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const coupon = await prisma.coupon.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!coupon) {
|
||||||
|
return res.status(404).json({
|
||||||
|
status: false,
|
||||||
|
message: 'Coupon not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedCoupon = await prisma.coupon.update({
|
||||||
|
where: { id },
|
||||||
|
data: { isActive: !coupon.isActive },
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
status: true,
|
||||||
|
message: `Coupon ${updatedCoupon.isActive ? 'activated' : 'deactivated'} successfully`,
|
||||||
|
data: updatedCoupon,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @desc Get coupon statistics
|
||||||
|
* @route GET /api/admin/coupons/stats/overview
|
||||||
|
* @access Private/Admin
|
||||||
|
*/
|
||||||
|
exports.getCouponStats = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const [totalCoupons, activeCoupons, expiredCoupons, totalRedemptions] =
|
||||||
|
await Promise.all([
|
||||||
|
prisma.coupon.count(),
|
||||||
|
prisma.coupon.count({ where: { isActive: true } }),
|
||||||
|
prisma.coupon.count({
|
||||||
|
where: {
|
||||||
|
validUntil: { lt: new Date() },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.coupon.aggregate({
|
||||||
|
_sum: { usedCount: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Get most used coupons
|
||||||
|
const mostUsed = await prisma.coupon.findMany({
|
||||||
|
where: { usedCount: { gt: 0 } },
|
||||||
|
orderBy: { usedCount: 'desc' },
|
||||||
|
take: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
status: true,
|
||||||
|
message: 'Coupon statistics fetched successfully',
|
||||||
|
data: {
|
||||||
|
totalCoupons,
|
||||||
|
activeCoupons,
|
||||||
|
expiredCoupons,
|
||||||
|
totalRedemptions: totalRedemptions._sum.usedCount || 0,
|
||||||
|
mostUsedCoupons: mostUsed,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
589
src/controllers/admin/dashboardController.js
Normal file
589
src/controllers/admin/dashboardController.js
Normal file
@@ -0,0 +1,589 @@
|
|||||||
|
const { prisma } = require('../../config/database');
|
||||||
|
const Product = require('../../models/mongodb/Product');
|
||||||
|
const {
|
||||||
|
getLowStockProducts,
|
||||||
|
getInventoryStats,
|
||||||
|
} = require('../../services/inventoryService');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @desc Get dashboard stats with order overview graphs
|
||||||
|
* @route GET /api/admin/dashboard/stats
|
||||||
|
* @access Private/Admin
|
||||||
|
*/
|
||||||
|
|
||||||
|
exports.getDashboardStats = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const [
|
||||||
|
totalUsers,
|
||||||
|
totalOrders,
|
||||||
|
totalProducts,
|
||||||
|
totalRevenue,
|
||||||
|
recentOrders,
|
||||||
|
topSellingProducts,
|
||||||
|
lowStockProducts,
|
||||||
|
inventoryStats,
|
||||||
|
orderOverview,
|
||||||
|
revenueOverview,
|
||||||
|
ordersByStatus,
|
||||||
|
monthlyComparison,
|
||||||
|
] = await Promise.all([
|
||||||
|
prisma.user.count(),
|
||||||
|
prisma.order.count(),
|
||||||
|
Product.countDocuments({ status: 'active' }),
|
||||||
|
prisma.order.aggregate({
|
||||||
|
_sum: { totalAmount: true },
|
||||||
|
where: { paymentStatus: 'PAID' },
|
||||||
|
}),
|
||||||
|
prisma.order.findMany({
|
||||||
|
take: 5,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: { id: true, email: true, firstName: true, lastName: true },
|
||||||
|
},
|
||||||
|
items: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
getTopSellingProducts(), // ✅ Real top sellers
|
||||||
|
getLowStockProducts(10), // ✅ Low stock alerts
|
||||||
|
getInventoryStats(), // ✅ Inventory overview
|
||||||
|
getOrderOverview(),
|
||||||
|
getRevenueOverview(),
|
||||||
|
getOrdersByStatus(),
|
||||||
|
getMonthlyComparison(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Enhance recent orders with product images
|
||||||
|
const orderProductIds = recentOrders
|
||||||
|
.flatMap(order => order.items.map(item => item.productId))
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const orderProducts = await Product.find({
|
||||||
|
_id: { $in: orderProductIds },
|
||||||
|
}).lean();
|
||||||
|
|
||||||
|
const productMap = {};
|
||||||
|
orderProducts.forEach(product => {
|
||||||
|
productMap[product._id.toString()] = {
|
||||||
|
...product,
|
||||||
|
displayImage: getProductImage(product),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const enhancedRecentOrders = recentOrders.map(order => ({
|
||||||
|
...order,
|
||||||
|
items: order.items.map(item => ({
|
||||||
|
...item,
|
||||||
|
productImage:
|
||||||
|
productMap[item.productId]?.displayImage ||
|
||||||
|
'https://via.placeholder.com/300',
|
||||||
|
productDetails: productMap[item.productId] || null,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
statusCode: 200,
|
||||||
|
status: true,
|
||||||
|
message: 'Dashboard stats fetched successfully',
|
||||||
|
data: {
|
||||||
|
// Summary Stats
|
||||||
|
totalUsers,
|
||||||
|
totalOrders,
|
||||||
|
totalProducts,
|
||||||
|
totalRevenue: parseFloat(totalRevenue._sum.totalAmount || 0),
|
||||||
|
|
||||||
|
// ✅ Inventory Stats
|
||||||
|
inventory: inventoryStats,
|
||||||
|
|
||||||
|
// Lists
|
||||||
|
recentOrders: enhancedRecentOrders,
|
||||||
|
topProducts: topSellingProducts, // ✅ Real selling data
|
||||||
|
lowStockProducts, // ✅ Products needing restock
|
||||||
|
|
||||||
|
// Charts
|
||||||
|
charts: {
|
||||||
|
orderOverview,
|
||||||
|
revenueOverview,
|
||||||
|
ordersByStatus,
|
||||||
|
monthlyComparison,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Dashboard stats error:', error);
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ✅ GET REAL TOP SELLING PRODUCTS
|
||||||
|
async function getTopSellingProducts() {
|
||||||
|
try {
|
||||||
|
// Get sales data from order items
|
||||||
|
const salesData = await prisma.orderItem.groupBy({
|
||||||
|
by: ['productId'],
|
||||||
|
_sum: { quantity: true },
|
||||||
|
_count: { productId: true },
|
||||||
|
orderBy: { _sum: { quantity: 'desc' } },
|
||||||
|
take: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (salesData.length === 0) {
|
||||||
|
// Fallback: Return recent products
|
||||||
|
const fallback = await Product.find({ status: 'active' })
|
||||||
|
.sort({ createdAt: -1 })
|
||||||
|
.limit(5)
|
||||||
|
.lean();
|
||||||
|
|
||||||
|
return fallback.map(p => ({
|
||||||
|
...p,
|
||||||
|
_id: p._id.toString(),
|
||||||
|
displayImage: getProductImage(p),
|
||||||
|
totalSold: 0,
|
||||||
|
totalOrders: 0,
|
||||||
|
stock: p.stock || 0,
|
||||||
|
stockStatus: getStockStatus(p.stock || 0),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const productIds = salesData.map(item => item.productId);
|
||||||
|
const products = await Product.find({
|
||||||
|
_id: { $in: productIds },
|
||||||
|
status: 'active',
|
||||||
|
}).lean();
|
||||||
|
|
||||||
|
const statsMap = {};
|
||||||
|
salesData.forEach(item => {
|
||||||
|
statsMap[item.productId] = {
|
||||||
|
totalSold: item._sum.quantity || 0,
|
||||||
|
totalOrders: item._count.productId || 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function calculateStock(product) {
|
||||||
|
if (product.variants?.length > 0) {
|
||||||
|
return product.variants
|
||||||
|
.filter(v => v.isActive)
|
||||||
|
.reduce((sum, v) => sum + (v.inventory?.quantity || 0), 0);
|
||||||
|
}
|
||||||
|
return product.stock || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const topProducts = products
|
||||||
|
.map(product => {
|
||||||
|
const stats = statsMap[product._id.toString()] || {
|
||||||
|
totalSold: 0,
|
||||||
|
totalOrders: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const stock =
|
||||||
|
product.variants?.length > 0
|
||||||
|
? product.variants
|
||||||
|
.filter(v => v.isActive)
|
||||||
|
.reduce((sum, v) => sum + (v.inventory?.quantity || 0), 0)
|
||||||
|
: product.stock || 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
_id: product._id.toString(),
|
||||||
|
name: product.name,
|
||||||
|
slug: product.slug,
|
||||||
|
basePrice: product.basePrice,
|
||||||
|
displayImage: getProductImage(product),
|
||||||
|
totalSold: stats.totalSold,
|
||||||
|
totalOrders: stats.totalOrders,
|
||||||
|
revenue: stats.totalSold * (product.basePrice || 0),
|
||||||
|
stock,
|
||||||
|
stockStatus: getStockStatus(stock),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.totalSold - a.totalSold)
|
||||||
|
.slice(0, 5);
|
||||||
|
|
||||||
|
return topProducts;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching top selling products:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStockStatus(stock) {
|
||||||
|
if (stock === 0) return 'OUT_OF_STOCK';
|
||||||
|
if (stock <= 5) return 'CRITICAL';
|
||||||
|
if (stock <= 10) return 'LOW';
|
||||||
|
return 'IN_STOCK';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProductImage(product) {
|
||||||
|
return (
|
||||||
|
product.images?.gallery?.[0] ||
|
||||||
|
product.images?.primary ||
|
||||||
|
product.variants?.[0]?.images?.[0] ||
|
||||||
|
'https://via.placeholder.com/300'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get daily order count for last 30 days
|
||||||
|
*/
|
||||||
|
// async function getOrderOverview() {
|
||||||
|
// const thirtyDaysAgo = new Date();
|
||||||
|
// thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||||
|
|
||||||
|
// // Group orders by date
|
||||||
|
// const orders = await prisma.order.findMany({
|
||||||
|
// where: {
|
||||||
|
// createdAt: {
|
||||||
|
// gte: thirtyDaysAgo,
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
// select: {
|
||||||
|
// createdAt: true,
|
||||||
|
// status: true,
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
|
||||||
|
// // Create date map for last 30 days
|
||||||
|
// const dateMap = {};
|
||||||
|
// for (let i = 29; i >= 0; i--) {
|
||||||
|
// const date = new Date();
|
||||||
|
// date.setDate(date.getDate() - i);
|
||||||
|
// const dateKey = date.toISOString().split('T')[0];
|
||||||
|
// dateMap[dateKey] = { total: 0, completed: 0, pending: 0, cancelled: 0 };
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // Count orders by date
|
||||||
|
// orders.forEach(order => {
|
||||||
|
// const dateKey = order.createdAt.toISOString().split('T')[0];
|
||||||
|
// if (dateMap[dateKey]) {
|
||||||
|
// dateMap[dateKey].total++;
|
||||||
|
|
||||||
|
// if (order.status === 'DELIVERED') {
|
||||||
|
// dateMap[dateKey].completed++;
|
||||||
|
// } else if (['PENDING', 'CONFIRMED', 'PROCESSING', 'SHIPPED'].includes(order.status)) {
|
||||||
|
// dateMap[dateKey].pending++;
|
||||||
|
// } else if (order.status === 'CANCELLED') {
|
||||||
|
// dateMap[dateKey].cancelled++;
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
|
||||||
|
// // Convert to array format for charts
|
||||||
|
// return Object.entries(dateMap).map(([date, counts]) => ({
|
||||||
|
// date,
|
||||||
|
// label: new Date(date).toLocaleDateString('en-IN', { month: 'short', day: 'numeric' }),
|
||||||
|
// total: counts.total,
|
||||||
|
// completed: counts.completed,
|
||||||
|
// pending: counts.pending,
|
||||||
|
// cancelled: counts.cancelled,
|
||||||
|
// }));
|
||||||
|
// }
|
||||||
|
|
||||||
|
async function getOrderOverview() {
|
||||||
|
const thirtyDaysAgo = new Date();
|
||||||
|
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||||
|
|
||||||
|
const orders = await prisma.order.findMany({
|
||||||
|
where: { createdAt: { gte: thirtyDaysAgo } },
|
||||||
|
select: { createdAt: true, status: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const dateMap = {};
|
||||||
|
for (let i = 29; i >= 0; i--) {
|
||||||
|
const date = new Date();
|
||||||
|
date.setDate(date.getDate() - i);
|
||||||
|
const dateKey = date.toISOString().split('T')[0];
|
||||||
|
dateMap[dateKey] = { total: 0, completed: 0, pending: 0, cancelled: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
orders.forEach(order => {
|
||||||
|
const dateKey = order.createdAt.toISOString().split('T')[0];
|
||||||
|
if (dateMap[dateKey]) {
|
||||||
|
dateMap[dateKey].total++;
|
||||||
|
if (order.status === 'DELIVERED') dateMap[dateKey].completed++;
|
||||||
|
else if (
|
||||||
|
['PENDING', 'CONFIRMED', 'PROCESSING', 'SHIPPED'].includes(order.status)
|
||||||
|
)
|
||||||
|
dateMap[dateKey].pending++;
|
||||||
|
else if (order.status === 'CANCELLED') dateMap[dateKey].cancelled++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return Object.entries(dateMap).map(([date, counts]) => ({
|
||||||
|
date,
|
||||||
|
label: new Date(date).toLocaleDateString('en-IN', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
}),
|
||||||
|
...counts,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get daily revenue for last 30 days
|
||||||
|
*/
|
||||||
|
// async function getRevenueOverview() {
|
||||||
|
// const thirtyDaysAgo = new Date();
|
||||||
|
// thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||||
|
|
||||||
|
// const orders = await prisma.order.findMany({
|
||||||
|
// where: {
|
||||||
|
// createdAt: {
|
||||||
|
// gte: thirtyDaysAgo,
|
||||||
|
// },
|
||||||
|
// paymentStatus: 'PAID',
|
||||||
|
// },
|
||||||
|
// select: {
|
||||||
|
// createdAt: true,
|
||||||
|
// totalAmount: true,
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
|
||||||
|
// // Create date map
|
||||||
|
// const dateMap = {};
|
||||||
|
// for (let i = 29; i >= 0; i--) {
|
||||||
|
// const date = new Date();
|
||||||
|
// date.setDate(date.getDate() - i);
|
||||||
|
// const dateKey = date.toISOString().split('T')[0];
|
||||||
|
// dateMap[dateKey] = 0;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // Sum revenue by date
|
||||||
|
// orders.forEach(order => {
|
||||||
|
// const dateKey = order.createdAt.toISOString().split('T')[0];
|
||||||
|
// if (dateMap[dateKey] !== undefined) {
|
||||||
|
// dateMap[dateKey] += parseFloat(order.totalAmount);
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
|
||||||
|
// return Object.entries(dateMap).map(([date, revenue]) => ({
|
||||||
|
// date,
|
||||||
|
// label: new Date(date).toLocaleDateString('en-IN', { month: 'short', day: 'numeric' }),
|
||||||
|
// revenue: Math.round(revenue * 100) / 100, // Round to 2 decimals
|
||||||
|
// }));
|
||||||
|
// }
|
||||||
|
|
||||||
|
async function getRevenueOverview() {
|
||||||
|
const thirtyDaysAgo = new Date();
|
||||||
|
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||||
|
|
||||||
|
const orders = await prisma.order.findMany({
|
||||||
|
where: { createdAt: { gte: thirtyDaysAgo }, paymentStatus: 'PAID' },
|
||||||
|
select: { createdAt: true, totalAmount: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const dateMap = {};
|
||||||
|
for (let i = 29; i >= 0; i--) {
|
||||||
|
const date = new Date();
|
||||||
|
date.setDate(date.getDate() - i);
|
||||||
|
dateMap[date.toISOString().split('T')[0]] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
orders.forEach(order => {
|
||||||
|
const dateKey = order.createdAt.toISOString().split('T')[0];
|
||||||
|
if (dateMap[dateKey] !== undefined) {
|
||||||
|
dateMap[dateKey] += parseFloat(order.totalAmount);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return Object.entries(dateMap).map(([date, revenue]) => ({
|
||||||
|
date,
|
||||||
|
label: new Date(date).toLocaleDateString('en-IN', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
}),
|
||||||
|
revenue: Math.round(revenue * 100) / 100,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get order counts by status
|
||||||
|
*/
|
||||||
|
// async function getOrdersByStatus() {
|
||||||
|
// const statusCounts = await prisma.order.groupBy({
|
||||||
|
// by: ['status'],
|
||||||
|
// _count: true,
|
||||||
|
// });
|
||||||
|
|
||||||
|
// const statusLabels = {
|
||||||
|
// PENDING: 'Pending',
|
||||||
|
// CONFIRMED: 'Confirmed',
|
||||||
|
// PROCESSING: 'Processing',
|
||||||
|
// SHIPPED: 'Shipped',
|
||||||
|
// DELIVERED: 'Delivered',
|
||||||
|
// CANCELLED: 'Cancelled',
|
||||||
|
// RETURN_REQUESTED: 'Return Requested',
|
||||||
|
// };
|
||||||
|
|
||||||
|
// const statusColors = {
|
||||||
|
// PENDING: '#FCD34D',
|
||||||
|
// CONFIRMED: '#60A5FA',
|
||||||
|
// PROCESSING: '#A78BFA',
|
||||||
|
// SHIPPED: '#C084FC',
|
||||||
|
// DELIVERED: '#34D399',
|
||||||
|
// CANCELLED: '#F87171',
|
||||||
|
// RETURN_REQUESTED: '#FB923C',
|
||||||
|
// };
|
||||||
|
|
||||||
|
// return statusCounts.map(item => ({
|
||||||
|
// status: item.status,
|
||||||
|
// label: statusLabels[item.status] || item.status,
|
||||||
|
// count: item._count,
|
||||||
|
// color: statusColors[item.status] || '#9CA3AF',
|
||||||
|
// }));
|
||||||
|
// }
|
||||||
|
|
||||||
|
async function getOrdersByStatus() {
|
||||||
|
const statusCounts = await prisma.order.groupBy({
|
||||||
|
by: ['status'],
|
||||||
|
_count: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const labels = {
|
||||||
|
PENDING: 'Pending',
|
||||||
|
CONFIRMED: 'Confirmed',
|
||||||
|
PROCESSING: 'Processing',
|
||||||
|
SHIPPED: 'Shipped',
|
||||||
|
DELIVERED: 'Delivered',
|
||||||
|
CANCELLED: 'Cancelled',
|
||||||
|
RETURN_REQUESTED: 'Return Requested',
|
||||||
|
};
|
||||||
|
|
||||||
|
const colors = {
|
||||||
|
PENDING: '#FCD34D',
|
||||||
|
CONFIRMED: '#60A5FA',
|
||||||
|
PROCESSING: '#A78BFA',
|
||||||
|
SHIPPED: '#C084FC',
|
||||||
|
DELIVERED: '#34D399',
|
||||||
|
CANCELLED: '#F87171',
|
||||||
|
RETURN_REQUESTED: '#FB923C',
|
||||||
|
};
|
||||||
|
|
||||||
|
return statusCounts.map(item => ({
|
||||||
|
status: item.status,
|
||||||
|
label: labels[item.status] || item.status,
|
||||||
|
count: item._count,
|
||||||
|
color: colors[item.status] || '#9CA3AF',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare current month vs previous month
|
||||||
|
*/
|
||||||
|
// async function getMonthlyComparison() {
|
||||||
|
// const now = new Date();
|
||||||
|
|
||||||
|
// // Current month dates
|
||||||
|
// const currentMonthStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
|
// const currentMonthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
||||||
|
|
||||||
|
// // Previous month dates
|
||||||
|
// const previousMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
||||||
|
// const previousMonthEnd = new Date(now.getFullYear(), now.getMonth(), 0);
|
||||||
|
|
||||||
|
// const [currentMonth, previousMonth] = await Promise.all([
|
||||||
|
// prisma.order.aggregate({
|
||||||
|
// where: {
|
||||||
|
// createdAt: {
|
||||||
|
// gte: currentMonthStart,
|
||||||
|
// lte: currentMonthEnd,
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
// _count: true,
|
||||||
|
// _sum: {
|
||||||
|
// totalAmount: true,
|
||||||
|
// },
|
||||||
|
// }),
|
||||||
|
// prisma.order.aggregate({
|
||||||
|
// where: {
|
||||||
|
// createdAt: {
|
||||||
|
// gte: previousMonthStart,
|
||||||
|
// lte: previousMonthEnd,
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
// _count: true,
|
||||||
|
// _sum: {
|
||||||
|
// totalAmount: true,
|
||||||
|
// },
|
||||||
|
// }),
|
||||||
|
// ]);
|
||||||
|
|
||||||
|
// const currentRevenue = parseFloat(currentMonth._sum.totalAmount || 0);
|
||||||
|
// const previousRevenue = parseFloat(previousMonth._sum.totalAmount || 0);
|
||||||
|
|
||||||
|
// const orderGrowth = previousMonth._count > 0
|
||||||
|
// ? ((currentMonth._count - previousMonth._count) / previousMonth._count) * 100
|
||||||
|
// : 100;
|
||||||
|
|
||||||
|
// const revenueGrowth = previousRevenue > 0
|
||||||
|
// ? ((currentRevenue - previousRevenue) / previousRevenue) * 100
|
||||||
|
// : 100;
|
||||||
|
|
||||||
|
// return {
|
||||||
|
// currentMonth: {
|
||||||
|
// orders: currentMonth._count,
|
||||||
|
// revenue: Math.round(currentRevenue * 100) / 100,
|
||||||
|
// label: currentMonthStart.toLocaleDateString('en-IN', { month: 'long' }),
|
||||||
|
// },
|
||||||
|
// previousMonth: {
|
||||||
|
// orders: previousMonth._count,
|
||||||
|
// revenue: Math.round(previousRevenue * 100) / 100,
|
||||||
|
// label: previousMonthStart.toLocaleDateString('en-IN', { month: 'long' }),
|
||||||
|
// },
|
||||||
|
// growth: {
|
||||||
|
// orders: Math.round(orderGrowth * 10) / 10,
|
||||||
|
// revenue: Math.round(revenueGrowth * 10) / 10,
|
||||||
|
// },
|
||||||
|
// };
|
||||||
|
// }
|
||||||
|
|
||||||
|
async function getMonthlyComparison() {
|
||||||
|
const now = new Date();
|
||||||
|
const currentMonthStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
|
const currentMonthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
||||||
|
const previousMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
||||||
|
const previousMonthEnd = new Date(now.getFullYear(), now.getMonth(), 0);
|
||||||
|
|
||||||
|
const [currentMonth, previousMonth] = await Promise.all([
|
||||||
|
prisma.order.aggregate({
|
||||||
|
where: { createdAt: { gte: currentMonthStart, lte: currentMonthEnd } },
|
||||||
|
_count: true,
|
||||||
|
_sum: { totalAmount: true },
|
||||||
|
}),
|
||||||
|
prisma.order.aggregate({
|
||||||
|
where: { createdAt: { gte: previousMonthStart, lte: previousMonthEnd } },
|
||||||
|
_count: true,
|
||||||
|
_sum: { totalAmount: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const currentRevenue = parseFloat(currentMonth._sum.totalAmount || 0);
|
||||||
|
const previousRevenue = parseFloat(previousMonth._sum.totalAmount || 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentMonth: {
|
||||||
|
orders: currentMonth._count,
|
||||||
|
revenue: Math.round(currentRevenue * 100) / 100,
|
||||||
|
label: currentMonthStart.toLocaleDateString('en-IN', { month: 'long' }),
|
||||||
|
},
|
||||||
|
previousMonth: {
|
||||||
|
orders: previousMonth._count,
|
||||||
|
revenue: Math.round(previousRevenue * 100) / 100,
|
||||||
|
label: previousMonthStart.toLocaleDateString('en-IN', { month: 'long' }),
|
||||||
|
},
|
||||||
|
growth: {
|
||||||
|
orders:
|
||||||
|
previousMonth._count > 0
|
||||||
|
? Math.round(
|
||||||
|
((currentMonth._count - previousMonth._count) /
|
||||||
|
previousMonth._count) *
|
||||||
|
1000
|
||||||
|
) / 10
|
||||||
|
: 100,
|
||||||
|
revenue:
|
||||||
|
previousRevenue > 0
|
||||||
|
? Math.round(
|
||||||
|
((currentRevenue - previousRevenue) / previousRevenue) * 1000
|
||||||
|
) / 10
|
||||||
|
: 100,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
395
src/controllers/admin/orderController.js
Normal file
395
src/controllers/admin/orderController.js
Normal file
@@ -0,0 +1,395 @@
|
|||||||
|
const { prisma } = require('../../config/database');
|
||||||
|
|
||||||
|
// exports.getAllOrders = async (req, res, next) => {
|
||||||
|
// try {
|
||||||
|
// const { page = 1, limit = 20, status, paymentStatus, userId } = req.query;
|
||||||
|
// const skip = (page - 1) * limit;
|
||||||
|
|
||||||
|
// const where = {};
|
||||||
|
// if (status) where.status = status;
|
||||||
|
// if (paymentStatus) where.paymentStatus = paymentStatus;
|
||||||
|
// if (userId) where.userId = userId;
|
||||||
|
|
||||||
|
// const [orders, total] = await Promise.all([
|
||||||
|
// prisma.order.findMany({
|
||||||
|
// where,
|
||||||
|
// include: {
|
||||||
|
// items: true,
|
||||||
|
// address: true,
|
||||||
|
// user: {
|
||||||
|
// select: { id: true, email: true, firstName: true, lastName: true },
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
// orderBy: { createdAt: 'desc' },
|
||||||
|
// skip: +skip,
|
||||||
|
// take: +limit,
|
||||||
|
// }),
|
||||||
|
// prisma.order.count({ where }),
|
||||||
|
// ]);
|
||||||
|
|
||||||
|
// // res.json({
|
||||||
|
// // success: true,
|
||||||
|
// // data: { orders, pagination: { page: +page, limit: +limit, total, pages: Math.ceil(total / limit) } },
|
||||||
|
// // });
|
||||||
|
// res.status(200).json({
|
||||||
|
// statusCode: 200,
|
||||||
|
// status: true,
|
||||||
|
// message: 'Orders fetched successfully',
|
||||||
|
// data: {
|
||||||
|
// orders,
|
||||||
|
// pagination: {
|
||||||
|
// page: +page,
|
||||||
|
// limit: +limit,
|
||||||
|
// total,
|
||||||
|
// pages: Math.ceil(total / limit),
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
// } catch (error) {
|
||||||
|
// next(error);
|
||||||
|
// }
|
||||||
|
// };
|
||||||
|
|
||||||
|
// exports.getOrderDetails = async (req, res, next) => {
|
||||||
|
// try {
|
||||||
|
// const { id } = req.params;
|
||||||
|
|
||||||
|
// const order = await prisma.order.findUnique({
|
||||||
|
// where: { id },
|
||||||
|
// include: {
|
||||||
|
// items: {
|
||||||
|
// include: {
|
||||||
|
// product: true, // if you want product details
|
||||||
|
// }
|
||||||
|
// },
|
||||||
|
// address: true,
|
||||||
|
// user: {
|
||||||
|
// select: {
|
||||||
|
// id: true,
|
||||||
|
// firstName: true,
|
||||||
|
// lastName: true,
|
||||||
|
// email: true,
|
||||||
|
// phone: true,
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
|
||||||
|
// if (!order) {
|
||||||
|
// return res.status(404).json({
|
||||||
|
// success: false,
|
||||||
|
// message: "Order not found",
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
// res.json({
|
||||||
|
// success: true,
|
||||||
|
// data: order,
|
||||||
|
// });
|
||||||
|
// } catch (error) {
|
||||||
|
// next(error);
|
||||||
|
// }
|
||||||
|
// };
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
exports.getAllOrders = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
page = 1,
|
||||||
|
limit = 20,
|
||||||
|
status,
|
||||||
|
search,
|
||||||
|
sortBy = 'createdAt',
|
||||||
|
sortOrder = 'desc',
|
||||||
|
} = req.query;
|
||||||
|
|
||||||
|
const skip = (parseInt(page) - 1) * parseInt(limit);
|
||||||
|
|
||||||
|
const where = {};
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
where.status = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
where.OR = [
|
||||||
|
{ orderNumber: { contains: search, mode: 'insensitive' } },
|
||||||
|
{ trackingNumber: { contains: search, mode: 'insensitive' } },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const [orders, total] = await Promise.all([
|
||||||
|
prisma.order.findMany({
|
||||||
|
where,
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
phone: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
address: true,
|
||||||
|
items: true,
|
||||||
|
// ✅ Include latest status change
|
||||||
|
statusHistory: {
|
||||||
|
take: 1,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
include: {
|
||||||
|
admin: {
|
||||||
|
select: {
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
[sortBy]: sortOrder,
|
||||||
|
},
|
||||||
|
skip,
|
||||||
|
take: parseInt(limit),
|
||||||
|
}),
|
||||||
|
prisma.order.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
orders,
|
||||||
|
pagination: {
|
||||||
|
total,
|
||||||
|
page: parseInt(page),
|
||||||
|
limit: parseInt(limit),
|
||||||
|
pages: Math.ceil(total / parseInt(limit)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Get all orders error:', error);
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// **
|
||||||
|
// * @desc Get single order with full history
|
||||||
|
// * @route GET /api/admin/orders/:orderId
|
||||||
|
// * @access Private/Admin
|
||||||
|
// */
|
||||||
|
exports.getOrderById = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { orderId } = req.params;
|
||||||
|
|
||||||
|
const order = await prisma.order.findUnique({
|
||||||
|
where: { id: orderId },
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
phone: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
address: true,
|
||||||
|
items: true,
|
||||||
|
// ✅ Include full status history
|
||||||
|
statusHistory: {
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
include: {
|
||||||
|
admin: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!order) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Order not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: order,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Get order error:', error);
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @desc Get order status history
|
||||||
|
* @route GET /api/admin/orders/:orderId/history
|
||||||
|
* @access Private/Admin
|
||||||
|
*/
|
||||||
|
exports.getOrderStatusHistory = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { orderId } = req.params;
|
||||||
|
|
||||||
|
const history = await prisma.orderStatusHistory.findMany({
|
||||||
|
where: { orderId },
|
||||||
|
include: {
|
||||||
|
admin: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: history,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Get status history error:', error);
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @desc Get status change statistics
|
||||||
|
* @route GET /api/admin/orders/stats/status-changes
|
||||||
|
* @access Private/Admin
|
||||||
|
*/
|
||||||
|
exports.getStatusChangeStats = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { startDate, endDate } = req.query;
|
||||||
|
|
||||||
|
const where = {};
|
||||||
|
|
||||||
|
if (startDate || endDate) {
|
||||||
|
where.createdAt = {};
|
||||||
|
if (startDate) where.createdAt.gte = new Date(startDate);
|
||||||
|
if (endDate) where.createdAt.lte = new Date(endDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count status changes by status
|
||||||
|
const statusChanges = await prisma.orderStatusHistory.groupBy({
|
||||||
|
by: ['toStatus'],
|
||||||
|
where,
|
||||||
|
_count: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Count status changes by admin
|
||||||
|
const changesByAdmin = await prisma.orderStatusHistory.groupBy({
|
||||||
|
by: ['changedBy'],
|
||||||
|
where,
|
||||||
|
_count: true,
|
||||||
|
orderBy: {
|
||||||
|
_count: {
|
||||||
|
changedBy: 'desc',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
take: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get admin details
|
||||||
|
const adminIds = changesByAdmin
|
||||||
|
.map(item => item.changedBy)
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const admins = await prisma.user.findMany({
|
||||||
|
where: { id: { in: adminIds } },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
email: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const adminMap = Object.fromEntries(
|
||||||
|
admins.map(admin => [admin.id, admin])
|
||||||
|
);
|
||||||
|
|
||||||
|
const changesByAdminWithDetails = changesByAdmin.map(item => ({
|
||||||
|
admin: item.changedBy ? adminMap[item.changedBy] : null,
|
||||||
|
count: item._count,
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
statusChanges,
|
||||||
|
changesByAdmin: changesByAdminWithDetails,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Get status stats error:', error);
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
exports.getOrderDetails = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const order = await prisma.order.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
items: true, // OrderItem snapshot data
|
||||||
|
address: true, // shipping address
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
email: true,
|
||||||
|
phone: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!order) {
|
||||||
|
return res.status(404).json({
|
||||||
|
// success: false,
|
||||||
|
// message: "Order not found",
|
||||||
|
statusCode: 404,
|
||||||
|
status: false,
|
||||||
|
message: 'Order not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// res.json({
|
||||||
|
// success: true,
|
||||||
|
// data: order,
|
||||||
|
// });
|
||||||
|
res.status(200).json({
|
||||||
|
statusCode: 200,
|
||||||
|
status: true,
|
||||||
|
message: 'Order details fetched successfully',
|
||||||
|
data: order,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
782
src/controllers/admin/productController.js
Normal file
782
src/controllers/admin/productController.js
Normal file
@@ -0,0 +1,782 @@
|
|||||||
|
const Product = require('../../models/mongodb/Product');
|
||||||
|
const uploadToS3 = require("../../utils/uploadToS3");
|
||||||
|
|
||||||
|
|
||||||
|
// exports.getAllProducts = async (req, res, next) => {
|
||||||
|
// try {
|
||||||
|
// const { page = 1, limit = 20, status, category, search } = req.query;
|
||||||
|
// const skip = (page - 1) * limit;
|
||||||
|
|
||||||
|
// const query = {};
|
||||||
|
// if (status) query.status = status;
|
||||||
|
// if (category) query.category = category;
|
||||||
|
// if (search) query.$text = { $search: search };
|
||||||
|
|
||||||
|
// const [products, total] = await Promise.all([
|
||||||
|
// Product.find(query)
|
||||||
|
// .sort({ createdAt: -1 })
|
||||||
|
// .skip(skip)
|
||||||
|
// .limit(+limit)
|
||||||
|
// .lean(),
|
||||||
|
// Product.countDocuments(query),
|
||||||
|
// ]);
|
||||||
|
|
||||||
|
// // res.json({
|
||||||
|
// // success: true,
|
||||||
|
// // data: { products, pagination: { page: +page, limit: +limit, total, pages: Math.ceil(total / limit) } },
|
||||||
|
// // });
|
||||||
|
// return res.status(200).json({
|
||||||
|
// statusCode: 200,
|
||||||
|
// status: true,
|
||||||
|
// message: 'Products fetched successfully',
|
||||||
|
// data: {
|
||||||
|
// products,
|
||||||
|
// pagination: {
|
||||||
|
// page: +page,
|
||||||
|
// limit: +limit,
|
||||||
|
// total,
|
||||||
|
// pages: Math.ceil(total / limit),
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
// } catch (error) {
|
||||||
|
// next(error);
|
||||||
|
// }
|
||||||
|
// };
|
||||||
|
|
||||||
|
// exports.createProduct = async (req, res, next) => {
|
||||||
|
// try {
|
||||||
|
// const product = new Product(req.body);
|
||||||
|
// await product.save();
|
||||||
|
// res.status(201).json({ success: true, message: 'Product created', data: product });
|
||||||
|
// } catch (error) {
|
||||||
|
// next(error);
|
||||||
|
// }
|
||||||
|
// };
|
||||||
|
|
||||||
|
// exports.createProduct = async (req, res, next) => {
|
||||||
|
// try {
|
||||||
|
// let { name, slug, ...rest } = req.body;
|
||||||
|
|
||||||
|
// // Generate slug from name if not provided
|
||||||
|
// if (!slug) {
|
||||||
|
// slug = name
|
||||||
|
// .toLowerCase()
|
||||||
|
// .replace(/[^a-z0-9]+/g, '-') // replace spaces & special chars
|
||||||
|
// .replace(/(^-|-$)/g, ''); // remove leading/trailing hyphens
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // Ensure slug is unique
|
||||||
|
// let slugExists = await Product.findOne({ slug });
|
||||||
|
// let counter = 1;
|
||||||
|
// const baseSlug = slug;
|
||||||
|
// while (slugExists) {
|
||||||
|
// slug = `${baseSlug}-${counter}`;
|
||||||
|
// slugExists = await Product.findOne({ slug });
|
||||||
|
// counter++;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // ✅ Upload images manually
|
||||||
|
// const primaryImage = req.files?.primaryImage
|
||||||
|
// ? await uploadToS3(req.files.primaryImage[0])
|
||||||
|
// : null;
|
||||||
|
|
||||||
|
// const galleryImages = req.files?.galleryImages
|
||||||
|
// ? await Promise.all(
|
||||||
|
// req.files.galleryImages.map((file) => uploadToS3(file))
|
||||||
|
// )
|
||||||
|
// : [];
|
||||||
|
|
||||||
|
// // const product = new Product({ name, slug, ...rest });
|
||||||
|
// const product = new Product({
|
||||||
|
// name,
|
||||||
|
// slug,
|
||||||
|
// ...rest,
|
||||||
|
// images: {
|
||||||
|
// primary: primaryImage,
|
||||||
|
// gallery: galleryImages,
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
// await product.save();
|
||||||
|
|
||||||
|
// // res.status(201).json({ success: true, message: 'Product created', data: product });
|
||||||
|
// return res.status(201).json({
|
||||||
|
// // statusCode: 201,
|
||||||
|
// status: true,
|
||||||
|
// message: 'Product created successfully',
|
||||||
|
// data: product,
|
||||||
|
// });
|
||||||
|
// } catch (error) {
|
||||||
|
// next(error);
|
||||||
|
// }
|
||||||
|
// };
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// exports.createProduct = async (req, res, next) => {
|
||||||
|
// try {
|
||||||
|
// let { name, hasVariants, variants, ...rest } = req.body;
|
||||||
|
// let slug;
|
||||||
|
// // slug generation
|
||||||
|
// if (!slug) {
|
||||||
|
// slug = name
|
||||||
|
// .toLowerCase()
|
||||||
|
// .replace(/[^a-z0-9]+/g, "-")
|
||||||
|
// .replace(/(^-|-$)/g, "");
|
||||||
|
// }
|
||||||
|
|
||||||
|
// let exists = await Product.findOne({ slug });
|
||||||
|
// let i = 1;
|
||||||
|
// while (exists) {
|
||||||
|
// slug = `${slug}-${i++}`;
|
||||||
|
// exists = await Product.findOne({ slug });
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // Upload main images
|
||||||
|
// const primaryImage = req.files?.primaryImage
|
||||||
|
// ? await uploadToS3(req.files.primaryImage[0])
|
||||||
|
// : null;
|
||||||
|
|
||||||
|
// const galleryImages = req.files?.galleryImages
|
||||||
|
// ? await Promise.all(
|
||||||
|
// req.files.galleryImages.map((f) => uploadToS3(f))
|
||||||
|
// )
|
||||||
|
// : [];
|
||||||
|
|
||||||
|
// const productData = {
|
||||||
|
// name,
|
||||||
|
// slug,
|
||||||
|
// ...rest,
|
||||||
|
// hasVariants: hasVariants === "true",
|
||||||
|
// };
|
||||||
|
|
||||||
|
// // 🔥 VARIANT LOGIC
|
||||||
|
// if (hasVariants === "true") {
|
||||||
|
// const parsedVariants = JSON.parse(variants); // IMPORTANT
|
||||||
|
|
||||||
|
// productData.variants = await Promise.all(
|
||||||
|
// parsedVariants.map(async (variant) => {
|
||||||
|
// const variantImages =
|
||||||
|
// req.files?.[`variantImages_${variant.color}`]
|
||||||
|
// ? await Promise.all(
|
||||||
|
// req.files[`variantImages_${variant.color}`].map(uploadToS3)
|
||||||
|
// )
|
||||||
|
// : [];
|
||||||
|
|
||||||
|
// return {
|
||||||
|
// size: variant.size,
|
||||||
|
// color: variant.color,
|
||||||
|
// sku: variant.sku,
|
||||||
|
// price: Number(variant.price),
|
||||||
|
// compareAtPrice: Number(variant.compareAtPrice),
|
||||||
|
// inventory: {
|
||||||
|
// quantity: Number(variant.quantity),
|
||||||
|
// trackInventory: true,
|
||||||
|
// },
|
||||||
|
// images: variantImages,
|
||||||
|
// };
|
||||||
|
// })
|
||||||
|
// );
|
||||||
|
// } else {
|
||||||
|
// // simple product images
|
||||||
|
// productData.images = {
|
||||||
|
// primary: primaryImage,
|
||||||
|
// gallery: galleryImages,
|
||||||
|
// };
|
||||||
|
// }
|
||||||
|
|
||||||
|
// const product = await Product.create(productData);
|
||||||
|
|
||||||
|
// res.status(201).json({
|
||||||
|
// status: true,
|
||||||
|
// message: "Product created successfully",
|
||||||
|
// data: product,
|
||||||
|
// });
|
||||||
|
// } catch (err) {
|
||||||
|
// next(err);
|
||||||
|
// }
|
||||||
|
// };
|
||||||
|
|
||||||
|
|
||||||
|
// exports.createProduct = async (req, res, next) => {
|
||||||
|
// try {
|
||||||
|
// const { name, hasVariants, variants, ...rest } = req.body;
|
||||||
|
|
||||||
|
// // ✅ Validate name
|
||||||
|
// if (!name || name.trim() === "") {
|
||||||
|
// return res.status(400).json({
|
||||||
|
// status: false,
|
||||||
|
// message: "Product name is required",
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // slug generation
|
||||||
|
// let slug = name
|
||||||
|
// .toLowerCase()
|
||||||
|
// .replace(/[^a-z0-9]+/g, "-")
|
||||||
|
// .replace(/(^-|-$)/g, "");
|
||||||
|
|
||||||
|
// // ensure unique slug
|
||||||
|
// let exists = await Product.findOne({ slug });
|
||||||
|
// let i = 1;
|
||||||
|
// while (exists) {
|
||||||
|
// slug = `${slug}-${i++}`;
|
||||||
|
// exists = await Product.findOne({ slug });
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // Upload main images
|
||||||
|
// const primaryImage = req.files?.primaryImage
|
||||||
|
// ? await uploadToS3(req.files.primaryImage[0])
|
||||||
|
// : null;
|
||||||
|
|
||||||
|
// const galleryImages = req.files?.galleryImages
|
||||||
|
// ? await Promise.all(req.files.galleryImages.map(uploadToS3))
|
||||||
|
// : [];
|
||||||
|
|
||||||
|
// const productData = {
|
||||||
|
// name,
|
||||||
|
// slug,
|
||||||
|
// ...rest,
|
||||||
|
// hasVariants: hasVariants === "true",
|
||||||
|
// };
|
||||||
|
|
||||||
|
// // 🔥 VARIANT LOGIC
|
||||||
|
// if (hasVariants === "true") {
|
||||||
|
// const parsedVariants = JSON.parse(variants); // IMPORTANT
|
||||||
|
|
||||||
|
// productData.variants = await Promise.all(
|
||||||
|
// parsedVariants.map(async (variant) => {
|
||||||
|
// const variantImages =
|
||||||
|
// req.files?.[`variantImages_${variant.color}`]
|
||||||
|
// ? await Promise.all(
|
||||||
|
// req.files[`variantImages_${variant.color}`].map(uploadToS3)
|
||||||
|
// )
|
||||||
|
// : [];
|
||||||
|
|
||||||
|
// return {
|
||||||
|
// size: variant.size,
|
||||||
|
// color: variant.color,
|
||||||
|
// sku: variant.sku,
|
||||||
|
// price: Number(variant.price),
|
||||||
|
// compareAtPrice: Number(variant.compareAtPrice),
|
||||||
|
// inventory: {
|
||||||
|
// quantity: Number(variant.quantity),
|
||||||
|
// trackInventory: true,
|
||||||
|
// },
|
||||||
|
// images: variantImages,
|
||||||
|
// };
|
||||||
|
// })
|
||||||
|
// );
|
||||||
|
// } else {
|
||||||
|
// // simple product images
|
||||||
|
// productData.images = {
|
||||||
|
// primary: primaryImage,
|
||||||
|
// gallery: galleryImages,
|
||||||
|
// };
|
||||||
|
// }
|
||||||
|
|
||||||
|
// const product = await Product.create(productData);
|
||||||
|
|
||||||
|
// res.status(201).json({
|
||||||
|
// status: true,
|
||||||
|
// message: "Product created successfully",
|
||||||
|
// data: product,
|
||||||
|
// });
|
||||||
|
// } catch (err) {
|
||||||
|
// next(err);
|
||||||
|
// }
|
||||||
|
// };
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// exports.createProduct = async (req, res, next) => {
|
||||||
|
// try {
|
||||||
|
// const { name, hasVariants, variants, ...rest } = req.body;
|
||||||
|
|
||||||
|
// console.log('📥 Request body:', { name, hasVariants, variantsCount: variants ? 'YES' : 'NO' });
|
||||||
|
// console.log('📥 Files received:', req.files ? req.files.length : 0);
|
||||||
|
|
||||||
|
// // ✅ Validate name
|
||||||
|
// if (!name || name.trim() === "") {
|
||||||
|
// return res.status(400).json({
|
||||||
|
// status: false,
|
||||||
|
// message: "Product name is required",
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // Generate unique slug
|
||||||
|
// let slug = name
|
||||||
|
// .toLowerCase()
|
||||||
|
// .replace(/[^a-z0-9]+/g, "-")
|
||||||
|
// .replace(/(^-|-$)/g, "");
|
||||||
|
|
||||||
|
// let exists = await Product.findOne({ slug });
|
||||||
|
// let i = 1;
|
||||||
|
// while (exists) {
|
||||||
|
// slug = `${slug}-${i++}`;
|
||||||
|
// exists = await Product.findOne({ slug });
|
||||||
|
// }
|
||||||
|
|
||||||
|
// const productData = {
|
||||||
|
// name,
|
||||||
|
// slug,
|
||||||
|
// ...rest,
|
||||||
|
// hasVariants: hasVariants === "true" || hasVariants === true,
|
||||||
|
// };
|
||||||
|
|
||||||
|
// // ======================
|
||||||
|
// // VARIANT MODE
|
||||||
|
// // ======================
|
||||||
|
// if (productData.hasVariants) {
|
||||||
|
// console.log('🎨 Processing variant product...');
|
||||||
|
|
||||||
|
// if (!variants) {
|
||||||
|
// return res.status(400).json({
|
||||||
|
// status: false,
|
||||||
|
// message: "Variants data is required when hasVariants is true",
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
// const parsedVariants = JSON.parse(variants);
|
||||||
|
// console.log('📦 Parsed variants:', parsedVariants.length);
|
||||||
|
|
||||||
|
// // ✅ Convert req.files array to object grouped by fieldname
|
||||||
|
// const filesGrouped = {};
|
||||||
|
// if (req.files && req.files.length > 0) {
|
||||||
|
// req.files.forEach(file => {
|
||||||
|
// if (!filesGrouped[file.fieldname]) {
|
||||||
|
// filesGrouped[file.fieldname] = [];
|
||||||
|
// }
|
||||||
|
// filesGrouped[file.fieldname].push(file);
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
// console.log('📸 Files grouped:', Object.keys(filesGrouped));
|
||||||
|
|
||||||
|
// // Process each variant
|
||||||
|
// productData.variants = await Promise.all(
|
||||||
|
// parsedVariants.map(async (variant) => {
|
||||||
|
// const color = variant.color;
|
||||||
|
// const fieldName = `variantImages_${color}`;
|
||||||
|
|
||||||
|
// console.log(`🔍 Looking for images with fieldname: ${fieldName}`);
|
||||||
|
|
||||||
|
// // Get images for this variant
|
||||||
|
// const variantFiles = filesGrouped[fieldName] || [];
|
||||||
|
// console.log(`📸 Found ${variantFiles.length} images for ${color}`);
|
||||||
|
|
||||||
|
// // Upload images to S3
|
||||||
|
// const variantImages = variantFiles.length > 0
|
||||||
|
// ? await Promise.all(variantFiles.map(uploadToS3))
|
||||||
|
// : [];
|
||||||
|
|
||||||
|
// console.log(`✅ Uploaded ${variantImages.length} images for ${color}`);
|
||||||
|
|
||||||
|
// return {
|
||||||
|
// size: variant.size || 'default',
|
||||||
|
// color: variant.color,
|
||||||
|
// sku: variant.sku,
|
||||||
|
// price: Number(variant.price),
|
||||||
|
// compareAtPrice: variant.compareAtPrice ? Number(variant.compareAtPrice) : null,
|
||||||
|
// inventory: {
|
||||||
|
// quantity: Number(variant.quantity || variant.stock || 0),
|
||||||
|
// trackInventory: true,
|
||||||
|
// },
|
||||||
|
// images: variantImages,
|
||||||
|
// isActive: true,
|
||||||
|
// };
|
||||||
|
// })
|
||||||
|
// );
|
||||||
|
|
||||||
|
// console.log('✅ All variants processed:', productData.variants.length);
|
||||||
|
// }
|
||||||
|
// // ======================
|
||||||
|
// // SIMPLE PRODUCT MODE
|
||||||
|
// // ======================
|
||||||
|
// else {
|
||||||
|
// console.log('📦 Processing simple product...');
|
||||||
|
|
||||||
|
// // ✅ Handle files from req.files array
|
||||||
|
// let primaryImage = null;
|
||||||
|
// let galleryImages = [];
|
||||||
|
|
||||||
|
// if (req.files && req.files.length > 0) {
|
||||||
|
// // Group files by fieldname
|
||||||
|
// const filesGrouped = {};
|
||||||
|
// req.files.forEach(file => {
|
||||||
|
// if (!filesGrouped[file.fieldname]) {
|
||||||
|
// filesGrouped[file.fieldname] = [];
|
||||||
|
// }
|
||||||
|
// filesGrouped[file.fieldname].push(file);
|
||||||
|
// });
|
||||||
|
|
||||||
|
// // Upload primary image
|
||||||
|
// if (filesGrouped['primaryImage'] && filesGrouped['primaryImage'][0]) {
|
||||||
|
// primaryImage = await uploadToS3(filesGrouped['primaryImage'][0]);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // Upload gallery images
|
||||||
|
// if (filesGrouped['galleryImages']) {
|
||||||
|
// galleryImages = await Promise.all(
|
||||||
|
// filesGrouped['galleryImages'].map(uploadToS3)
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// productData.images = {
|
||||||
|
// primary: primaryImage,
|
||||||
|
// gallery: galleryImages,
|
||||||
|
// videos: [],
|
||||||
|
// };
|
||||||
|
|
||||||
|
// console.log('✅ Images uploaded:', {
|
||||||
|
// primary: !!primaryImage,
|
||||||
|
// gallery: galleryImages.length,
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // Create product in MongoDB
|
||||||
|
// const product = await Product.create(productData);
|
||||||
|
|
||||||
|
// console.log('✅ Product created:', product._id);
|
||||||
|
|
||||||
|
// res.status(201).json({
|
||||||
|
// status: true,
|
||||||
|
// message: "Product created successfully",
|
||||||
|
// data: product,
|
||||||
|
// });
|
||||||
|
// } catch (err) {
|
||||||
|
// console.error('❌ Error creating product:', err);
|
||||||
|
|
||||||
|
// // Send detailed error for debugging
|
||||||
|
// if (process.env.NODE_ENV === 'development') {
|
||||||
|
// return res.status(400).json({
|
||||||
|
// status: false,
|
||||||
|
// message: "Failed to create product",
|
||||||
|
// error: err.message,
|
||||||
|
// stack: err.stack,
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
// next(err);
|
||||||
|
// }
|
||||||
|
// };
|
||||||
|
|
||||||
|
|
||||||
|
exports.getAllProducts = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { page = 1, limit = 20, status, category, search } = req.query;
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
|
const query = {};
|
||||||
|
if (status) query.status = status;
|
||||||
|
if (category) query.category = category;
|
||||||
|
if (search) query.$text = { $search: search };
|
||||||
|
|
||||||
|
const [products, total] = await Promise.all([
|
||||||
|
Product.find(query)
|
||||||
|
.sort({ createdAt: -1 })
|
||||||
|
.skip(skip)
|
||||||
|
.limit(+limit)
|
||||||
|
.lean(),
|
||||||
|
Product.countDocuments(query),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ✅ Compute real-time stock for each product
|
||||||
|
const productsWithStock = products.map(product => {
|
||||||
|
const stockInfo = computeStockInfo(product);
|
||||||
|
return {
|
||||||
|
...product,
|
||||||
|
...stockInfo,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
statusCode: 200,
|
||||||
|
status: true,
|
||||||
|
message: 'Products fetched successfully',
|
||||||
|
data: {
|
||||||
|
products: productsWithStock,
|
||||||
|
pagination: {
|
||||||
|
page: +page,
|
||||||
|
limit: +limit,
|
||||||
|
total,
|
||||||
|
pages: Math.ceil(total / limit),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// ✅ Central stock calculator — use this everywhere
|
||||||
|
function computeStockInfo(product) {
|
||||||
|
let totalStock = 0;
|
||||||
|
let variantStockDetails = [];
|
||||||
|
|
||||||
|
if (product.hasVariants && product.variants?.length > 0) {
|
||||||
|
// Sum stock across all active variants
|
||||||
|
variantStockDetails = product.variants
|
||||||
|
.filter(v => v.isActive)
|
||||||
|
.map(v => ({
|
||||||
|
variantId: v._id?.toString(),
|
||||||
|
sku: v.sku,
|
||||||
|
size: v.size,
|
||||||
|
color: v.color,
|
||||||
|
price: v.price,
|
||||||
|
stock: v.inventory?.quantity || 0,
|
||||||
|
trackInventory: v.inventory?.trackInventory ?? true,
|
||||||
|
stockStatus: getStockStatus(v.inventory?.quantity || 0),
|
||||||
|
}));
|
||||||
|
|
||||||
|
totalStock = variantStockDetails.reduce((sum, v) => sum + v.stock, 0);
|
||||||
|
} else {
|
||||||
|
// Non-variant product — use root stock field
|
||||||
|
totalStock = product.stock || 0;
|
||||||
|
variantStockDetails = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalStock,
|
||||||
|
stockStatus: getStockStatus(totalStock),
|
||||||
|
variantStock: variantStockDetails, // per-variant breakdown
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStockStatus(stock) {
|
||||||
|
if (stock === 0) return 'OUT_OF_STOCK';
|
||||||
|
if (stock <= 5) return 'CRITICAL';
|
||||||
|
if (stock <= 10) return 'LOW';
|
||||||
|
return 'IN_STOCK';
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
exports.createProduct = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { name, hasVariants, variants, ...rest } = req.body;
|
||||||
|
|
||||||
|
console.log('📥 Request body:', { name, hasVariants, variantsCount: variants ? 'YES' : 'NO' });
|
||||||
|
console.log('📥 Files received:', req.files ? req.files.length : 0);
|
||||||
|
|
||||||
|
// ✅ Validate name
|
||||||
|
if (!name || name.trim() === "") {
|
||||||
|
return res.status(400).json({
|
||||||
|
status: false,
|
||||||
|
message: "Product name is required",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate unique slug
|
||||||
|
let slug = name
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/(^-|-$)/g, "");
|
||||||
|
|
||||||
|
let exists = await Product.findOne({ slug });
|
||||||
|
let i = 1;
|
||||||
|
while (exists) {
|
||||||
|
slug = `${slug}-${i++}`;
|
||||||
|
exists = await Product.findOne({ slug });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Build productData carefully to avoid empty slug
|
||||||
|
const productData = {
|
||||||
|
name,
|
||||||
|
slug, // Generated slug
|
||||||
|
hasVariants: hasVariants === "true" || hasVariants === true,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ✅ Add other fields from rest, but skip 'slug' if it exists
|
||||||
|
Object.keys(rest).forEach(key => {
|
||||||
|
if (key !== 'slug') {
|
||||||
|
productData[key] = rest[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ======================
|
||||||
|
// VARIANT MODE
|
||||||
|
// ======================
|
||||||
|
if (productData.hasVariants) {
|
||||||
|
console.log('🎨 Processing variant product...');
|
||||||
|
|
||||||
|
if (!variants) {
|
||||||
|
return res.status(400).json({
|
||||||
|
status: false,
|
||||||
|
message: "Variants data is required when hasVariants is true",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedVariants = JSON.parse(variants);
|
||||||
|
console.log('📦 Parsed variants:', parsedVariants.length);
|
||||||
|
|
||||||
|
// ✅ Convert req.files array to object grouped by fieldname
|
||||||
|
const filesGrouped = {};
|
||||||
|
if (req.files && req.files.length > 0) {
|
||||||
|
req.files.forEach(file => {
|
||||||
|
if (!filesGrouped[file.fieldname]) {
|
||||||
|
filesGrouped[file.fieldname] = [];
|
||||||
|
}
|
||||||
|
filesGrouped[file.fieldname].push(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('📸 Files grouped:', Object.keys(filesGrouped));
|
||||||
|
|
||||||
|
// Process each variant
|
||||||
|
productData.variants = await Promise.all(
|
||||||
|
parsedVariants.map(async (variant) => {
|
||||||
|
const color = variant.color;
|
||||||
|
const fieldName = `variantImages_${color}`;
|
||||||
|
|
||||||
|
console.log(`🔍 Looking for images with fieldname: ${fieldName}`);
|
||||||
|
|
||||||
|
// Get images for this variant
|
||||||
|
const variantFiles = filesGrouped[fieldName] || [];
|
||||||
|
console.log(`📸 Found ${variantFiles.length} images for ${color}`);
|
||||||
|
|
||||||
|
// Upload images to S3
|
||||||
|
const variantImages = variantFiles.length > 0
|
||||||
|
? await Promise.all(variantFiles.map(uploadToS3))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
console.log(`✅ Uploaded ${variantImages.length} images for ${color}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
size: variant.size || 'default',
|
||||||
|
color: variant.color,
|
||||||
|
sku: variant.sku,
|
||||||
|
price: Number(variant.price),
|
||||||
|
compareAtPrice: variant.compareAtPrice ? Number(variant.compareAtPrice) : null,
|
||||||
|
inventory: {
|
||||||
|
quantity: Number(variant.quantity || variant.stock || 0),
|
||||||
|
trackInventory: true,
|
||||||
|
},
|
||||||
|
images: variantImages,
|
||||||
|
isActive: true,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('✅ All variants processed:', productData.variants.length);
|
||||||
|
}
|
||||||
|
// ======================
|
||||||
|
// SIMPLE PRODUCT MODE
|
||||||
|
// ======================
|
||||||
|
else {
|
||||||
|
console.log('📦 Processing simple product...');
|
||||||
|
|
||||||
|
// ✅ Handle files from req.files array
|
||||||
|
let primaryImage = null;
|
||||||
|
let galleryImages = [];
|
||||||
|
|
||||||
|
if (req.files && req.files.length > 0) {
|
||||||
|
// Group files by fieldname
|
||||||
|
const filesGrouped = {};
|
||||||
|
req.files.forEach(file => {
|
||||||
|
if (!filesGrouped[file.fieldname]) {
|
||||||
|
filesGrouped[file.fieldname] = [];
|
||||||
|
}
|
||||||
|
filesGrouped[file.fieldname].push(file);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Upload primary image
|
||||||
|
if (filesGrouped['primaryImage'] && filesGrouped['primaryImage'][0]) {
|
||||||
|
primaryImage = await uploadToS3(filesGrouped['primaryImage'][0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload gallery images
|
||||||
|
if (filesGrouped['galleryImages']) {
|
||||||
|
galleryImages = await Promise.all(
|
||||||
|
filesGrouped['galleryImages'].map(uploadToS3)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
productData.images = {
|
||||||
|
primary: primaryImage,
|
||||||
|
gallery: galleryImages,
|
||||||
|
videos: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('✅ Images uploaded:', {
|
||||||
|
primary: !!primaryImage,
|
||||||
|
gallery: galleryImages.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create product in MongoDB
|
||||||
|
const product = await Product.create(productData);
|
||||||
|
|
||||||
|
console.log('✅ Product created:', product._id);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
status: true,
|
||||||
|
message: "Product created successfully",
|
||||||
|
data: product,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('❌ Error creating product:', err);
|
||||||
|
|
||||||
|
// Send detailed error for debugging
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
return res.status(400).json({
|
||||||
|
status: false,
|
||||||
|
message: "Failed to create product",
|
||||||
|
error: err.message,
|
||||||
|
stack: err.stack,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
exports.updateProduct = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const product = await Product.findByIdAndUpdate(req.params.id, req.body, {
|
||||||
|
new: true,
|
||||||
|
});
|
||||||
|
// if (!product) return res.status(404).json({ success: false, message: 'Product not found' });
|
||||||
|
if (!product) {
|
||||||
|
return res.status(404).json({
|
||||||
|
statusCode: 404,
|
||||||
|
status: false,
|
||||||
|
message: 'Product not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// res.json({ success: true, message: 'Product updated', data: product });
|
||||||
|
return res.status(200).json({
|
||||||
|
statusCode: 200,
|
||||||
|
status: true,
|
||||||
|
message: 'Product updated successfully',
|
||||||
|
data: product,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.deleteProduct = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const product = await Product.findByIdAndDelete(req.params.id);
|
||||||
|
// if (!product) return res.status(404).json({ success: false, message: 'Product not found' });
|
||||||
|
if (!product) {
|
||||||
|
return res.status(404).json({
|
||||||
|
statusCode: 404,
|
||||||
|
status: false,
|
||||||
|
message: 'Product not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// res.json({ success: true, message: 'Product deleted' });
|
||||||
|
return res.status(200).json({
|
||||||
|
statusCode: 200,
|
||||||
|
status: true,
|
||||||
|
message: 'Product deleted successfully',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
1170
src/controllers/admin/reportController.js
Normal file
1170
src/controllers/admin/reportController.js
Normal file
File diff suppressed because it is too large
Load Diff
160
src/controllers/admin/userController.js
Normal file
160
src/controllers/admin/userController.js
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
const { prisma } = require('../../config/database');
|
||||||
|
|
||||||
|
exports.getAllUsers = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { page = 1, limit = 20, role, search, isActive } = req.query;
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
|
const where = {};
|
||||||
|
if (role) where.role = role;
|
||||||
|
if (isActive !== undefined) where.isActive = isActive === 'true';
|
||||||
|
if (search) {
|
||||||
|
where.OR = [
|
||||||
|
{ email: { contains: search, mode: 'insensitive' } },
|
||||||
|
{ firstName: { contains: search, mode: 'insensitive' } },
|
||||||
|
{ lastName: { contains: search, mode: 'insensitive' } },
|
||||||
|
{ username: { contains: search, mode: 'insensitive' } },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const [users, total] = await Promise.all([
|
||||||
|
prisma.user.findMany({
|
||||||
|
where,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
username: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
role: true,
|
||||||
|
isVerified: true,
|
||||||
|
isActive: true,
|
||||||
|
createdAt: true,
|
||||||
|
lastLoginAt: true,
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
skip: parseInt(skip),
|
||||||
|
take: parseInt(limit),
|
||||||
|
}),
|
||||||
|
prisma.user.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// res.json({
|
||||||
|
// success: true,
|
||||||
|
// data: { users, pagination: { page: +page, limit: +limit, total, pages: Math.ceil(total / limit) } },
|
||||||
|
// });
|
||||||
|
return res.status(200).json({
|
||||||
|
statusCode: 200,
|
||||||
|
status: true,
|
||||||
|
message: 'Users fetched successfully',
|
||||||
|
data: {
|
||||||
|
users,
|
||||||
|
pagination: {
|
||||||
|
page: +page,
|
||||||
|
limit: +limit,
|
||||||
|
total,
|
||||||
|
pages: Math.ceil(total / limit),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.updateUserStatus = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { isActive, role } = req.body;
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({ where: { id } });
|
||||||
|
// if (!user) return res.status(404).json({ success: false, message: 'User not found' });
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404).json({
|
||||||
|
statusCode: 404,
|
||||||
|
status: false,
|
||||||
|
message: 'User not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedUser = await prisma.user.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...(isActive !== undefined && { isActive }),
|
||||||
|
...(role && { role }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// res.json({ success: true, message: 'User updated successfully', data: updatedUser });
|
||||||
|
return res.status(200).json({
|
||||||
|
statusCode: 200,
|
||||||
|
status: true,
|
||||||
|
message: 'User updated successfully',
|
||||||
|
data: updatedUser,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.getUserById = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
// if (!id) {
|
||||||
|
// return res.status(400).json({
|
||||||
|
// success: false,
|
||||||
|
// message: "User ID is required",
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return res.status(400).json({
|
||||||
|
statusCode: 400,
|
||||||
|
status: false,
|
||||||
|
message: 'User ID is required',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
username: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
role: true,
|
||||||
|
isVerified: true,
|
||||||
|
isActive: true,
|
||||||
|
createdAt: true,
|
||||||
|
lastLoginAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// if (!user) {
|
||||||
|
// return res.status(404).json({
|
||||||
|
// success: false,
|
||||||
|
// message: "User not found",
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404).json({
|
||||||
|
statusCode: 404,
|
||||||
|
status: false,
|
||||||
|
message: 'User not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// res.json({ success: true, data: { user } });
|
||||||
|
return res.status(200).json({
|
||||||
|
statusCode: 200,
|
||||||
|
status: true,
|
||||||
|
message: 'User details fetched successfully',
|
||||||
|
data: { user },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
421
src/controllers/authController.js
Normal file
421
src/controllers/authController.js
Normal file
@@ -0,0 +1,421 @@
|
|||||||
|
const authService = require('../services/authService');
|
||||||
|
const { generateOTP, sendOTP } = require('../services/wappconnectService');
|
||||||
|
const { saveOTP, verifyOTP } = require('../services/otpStore');
|
||||||
|
const { prisma } = require('../config/database');
|
||||||
|
|
||||||
|
// @desc Register user
|
||||||
|
exports.register = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { email, password, firstName, lastName, username, phone, role } =
|
||||||
|
req.body;
|
||||||
|
|
||||||
|
if (!email || !password) {
|
||||||
|
return res.status(400).json({
|
||||||
|
statusCode: 400,
|
||||||
|
status: false,
|
||||||
|
message: 'Email and password are required',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length < 6) {
|
||||||
|
return res.status(400).json({
|
||||||
|
statusCode: 400,
|
||||||
|
status: false,
|
||||||
|
message: 'Password must be at least 6 characters',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await authService.register({
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
username,
|
||||||
|
phone,
|
||||||
|
role,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(201).json({
|
||||||
|
statusCode: 201,
|
||||||
|
status: true,
|
||||||
|
message: 'User registered successfully',
|
||||||
|
data: result,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return res.status(500).json({
|
||||||
|
statusCode: 500,
|
||||||
|
status: false,
|
||||||
|
message: error.message || 'Internal server error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// @desc Login user
|
||||||
|
exports.login = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { email, password } = req.body;
|
||||||
|
|
||||||
|
if (!email || !password) {
|
||||||
|
return res.status(400).json({
|
||||||
|
statusCode: 400,
|
||||||
|
status: false,
|
||||||
|
message: 'Email and password are required',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await authService.login(email, password);
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
statusCode: 200,
|
||||||
|
status: true,
|
||||||
|
message: 'Login successful',
|
||||||
|
data: result,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return res.status(500).json({
|
||||||
|
statusCode: 500,
|
||||||
|
status: false,
|
||||||
|
message: error.message || 'Internal server error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── STEP 1: Request OTP ─────────────────────────────────────────────────────
|
||||||
|
exports.sendLoginOTP = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { phone } = req.body;
|
||||||
|
|
||||||
|
if (!phone) {
|
||||||
|
return res.status(400).json({
|
||||||
|
status: false,
|
||||||
|
message: 'Phone number is required',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findFirst({ where: { phone } });
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404).json({
|
||||||
|
status: false,
|
||||||
|
message: 'No account found with this phone number',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.isActive) {
|
||||||
|
return res.status(403).json({
|
||||||
|
status: false,
|
||||||
|
message: 'Account is deactivated',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const otp = generateOTP();
|
||||||
|
saveOTP(phone, otp);
|
||||||
|
|
||||||
|
// ✅ Don't crash if WhatsApp fails — just log the error
|
||||||
|
try {
|
||||||
|
await sendOTP(phone, otp);
|
||||||
|
} catch (whatsappError) {
|
||||||
|
console.error('⚠️ WhatsApp send failed:', whatsappError.message);
|
||||||
|
// Continue anyway — OTP is still saved, user can use console OTP in dev
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always log OTP in development
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
console.log(`\n📱 OTP for ${phone}: ${otp}`);
|
||||||
|
console.log(`💡 Master OTP: 123456\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
status: true,
|
||||||
|
message: 'OTP sent successfully',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── STEP 2: Verify OTP & Login ──────────────────────────────────────────────
|
||||||
|
exports.verifyLoginOTP = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { phone, otp } = req.body;
|
||||||
|
|
||||||
|
if (!phone || !otp) {
|
||||||
|
return res.status(400).json({
|
||||||
|
statusCode: 400,
|
||||||
|
status: false,
|
||||||
|
message: 'Phone and OTP are required',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = verifyOTP(phone, otp);
|
||||||
|
if (!result.valid) {
|
||||||
|
return res.status(400).json({
|
||||||
|
statusCode: 400,
|
||||||
|
status: false,
|
||||||
|
message: result.reason,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findFirst({
|
||||||
|
where: { phone },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
phone: true,
|
||||||
|
role: true,
|
||||||
|
avatar: true,
|
||||||
|
isVerified: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404).json({
|
||||||
|
statusCode: 404,
|
||||||
|
status: false,
|
||||||
|
message: 'User not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: { lastLoginAt: new Date() },
|
||||||
|
});
|
||||||
|
|
||||||
|
// ✅ Use authService class methods directly (same as login does)
|
||||||
|
const token = authService.generateToken({ id: user.id });
|
||||||
|
const refreshToken = authService.generateRefreshToken({ id: user.id });
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
statusCode: 200,
|
||||||
|
status: true,
|
||||||
|
message: 'Login successful',
|
||||||
|
data: {
|
||||||
|
user,
|
||||||
|
token,
|
||||||
|
refreshToken,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// @desc Refresh token
|
||||||
|
// @desc Refresh token
|
||||||
|
exports.refreshToken = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { refreshToken } = req.body;
|
||||||
|
|
||||||
|
if (!refreshToken) {
|
||||||
|
return res.status(400).json({
|
||||||
|
statusCode: 400,
|
||||||
|
status: false,
|
||||||
|
message: 'Refresh token is required',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await authService.refreshToken(refreshToken);
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
statusCode: 200,
|
||||||
|
status: true,
|
||||||
|
message: 'Token refreshed successfully',
|
||||||
|
data: result,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return res.status(500).json({
|
||||||
|
statusCode: 500,
|
||||||
|
status: false,
|
||||||
|
message: error.message || 'Internal server error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// @desc Logout user
|
||||||
|
exports.logout = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
await authService.logout(req.user.id);
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
statusCode: 200,
|
||||||
|
status: true,
|
||||||
|
message: 'Logout successful',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return res.status(500).json({
|
||||||
|
statusCode: 500,
|
||||||
|
status: false,
|
||||||
|
message: error.message || 'Internal server error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// @desc Change password
|
||||||
|
exports.changePassword = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { currentPassword, newPassword } = req.body;
|
||||||
|
|
||||||
|
if (!currentPassword || !newPassword) {
|
||||||
|
return res.status(400).json({
|
||||||
|
statusCode: 400,
|
||||||
|
status: false,
|
||||||
|
message: 'Current password and new password are required',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPassword.length < 6) {
|
||||||
|
return res.status(400).json({
|
||||||
|
statusCode: 400,
|
||||||
|
status: false,
|
||||||
|
message: 'New password must be at least 6 characters',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await authService.changePassword(req.user.id, currentPassword, newPassword);
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
statusCode: 200,
|
||||||
|
status: true,
|
||||||
|
message: 'Password changed successfully',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return res.status(500).json({
|
||||||
|
statusCode: 500,
|
||||||
|
status: false,
|
||||||
|
message: error.message || 'Internal server error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// @desc Forgot password
|
||||||
|
exports.forgotPassword = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { email } = req.body;
|
||||||
|
|
||||||
|
if (!email) {
|
||||||
|
return res.status(400).json({
|
||||||
|
statusCode: 400,
|
||||||
|
status: false,
|
||||||
|
message: 'Email is required',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await authService.requestPasswordReset(email);
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
statusCode: 200,
|
||||||
|
status: true,
|
||||||
|
message: result.message || 'Password reset email sent successfully',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return res.status(500).json({
|
||||||
|
statusCode: 500,
|
||||||
|
status: false,
|
||||||
|
message: error.message || 'Internal server error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// @desc Reset password
|
||||||
|
exports.resetPassword = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { token, newPassword } = req.body;
|
||||||
|
|
||||||
|
if (!token || !newPassword) {
|
||||||
|
return res.status(400).json({
|
||||||
|
statusCode: 400,
|
||||||
|
status: false,
|
||||||
|
message: 'Token and new password are required',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPassword.length < 6) {
|
||||||
|
return res.status(400).json({
|
||||||
|
statusCode: 400,
|
||||||
|
status: false,
|
||||||
|
message: 'Password must be at least 6 characters',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await authService.resetPassword(token, newPassword);
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
statusCode: 200,
|
||||||
|
status: true,
|
||||||
|
message: 'Password reset successfully',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return res.status(500).json({
|
||||||
|
statusCode: 500,
|
||||||
|
status: false,
|
||||||
|
message: error.message || 'Internal server error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// @desc Send verification email
|
||||||
|
exports.sendVerification = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
await authService.sendVerificationEmail(req.user.id);
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
statusCode: 200,
|
||||||
|
status: true,
|
||||||
|
message: 'Verification email sent',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return res.status(500).json({
|
||||||
|
statusCode: 500,
|
||||||
|
status: false,
|
||||||
|
message: error.message || 'Internal server error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// @desc Verify email
|
||||||
|
exports.verifyEmail = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { token } = req.body;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return res.status(400).json({
|
||||||
|
statusCode: 400,
|
||||||
|
status: false,
|
||||||
|
message: 'Verification token is required',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await authService.verifyEmail(token);
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
statusCode: 200,
|
||||||
|
status: true,
|
||||||
|
message: 'Email verified successfully',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return res.status(500).json({
|
||||||
|
statusCode: 500,
|
||||||
|
status: false,
|
||||||
|
message: error.message || 'Internal server error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// @desc Get current user profile
|
||||||
|
exports.getMe = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
return res.status(200).json({
|
||||||
|
statusCode: 200,
|
||||||
|
status: true,
|
||||||
|
message: 'User retrieved successfully',
|
||||||
|
data: { user: req.user },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return res.status(500).json({
|
||||||
|
statusCode: 500,
|
||||||
|
status: false,
|
||||||
|
message: error.message || 'Internal server error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
351
src/controllers/couponController.js
Normal file
351
src/controllers/couponController.js
Normal file
@@ -0,0 +1,351 @@
|
|||||||
|
const { prisma } = require('../config/database');
|
||||||
|
const { Decimal } = require('@prisma/client/runtime/library');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @desc Validate and apply coupon code
|
||||||
|
* @route POST /api/coupons/validate
|
||||||
|
* @access Public/Private
|
||||||
|
*/
|
||||||
|
exports.validateCoupon = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { code, orderAmount } = req.body;
|
||||||
|
const userId = req.user?.id;
|
||||||
|
|
||||||
|
if (!code || !orderAmount) {
|
||||||
|
return res.status(400).json({
|
||||||
|
status: false,
|
||||||
|
message: 'Coupon code and order amount are required',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find coupon
|
||||||
|
const coupon = await prisma.coupon.findUnique({
|
||||||
|
where: { code: code.toUpperCase() },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!coupon) {
|
||||||
|
return res.status(404).json({
|
||||||
|
status: false,
|
||||||
|
message: 'Invalid coupon code',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate coupon
|
||||||
|
const validation = validateCouponRules(coupon, orderAmount);
|
||||||
|
|
||||||
|
if (!validation.isValid) {
|
||||||
|
return res.status(400).json({
|
||||||
|
status: false,
|
||||||
|
message: validation.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate discount
|
||||||
|
const discountAmount = calculateDiscount(coupon, orderAmount);
|
||||||
|
const finalAmount = orderAmount - discountAmount;
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
status: true,
|
||||||
|
message: 'Coupon applied successfully',
|
||||||
|
data: {
|
||||||
|
couponCode: coupon.code,
|
||||||
|
couponType: coupon.type,
|
||||||
|
discountAmount: parseFloat(discountAmount.toFixed(2)),
|
||||||
|
originalAmount: parseFloat(orderAmount.toFixed(2)),
|
||||||
|
finalAmount: parseFloat(finalAmount.toFixed(2)),
|
||||||
|
savings: parseFloat(discountAmount.toFixed(2)),
|
||||||
|
savingsPercentage: parseFloat(((discountAmount / orderAmount) * 100).toFixed(2)),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Validate coupon error:', error);
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @desc Get available coupons for user
|
||||||
|
* @route GET /api/coupons/available
|
||||||
|
* @access Public
|
||||||
|
*/
|
||||||
|
exports.getAvailableCoupons = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { orderAmount } = req.query;
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
// Get active coupons
|
||||||
|
const coupons = await prisma.coupon.findMany({
|
||||||
|
where: {
|
||||||
|
isActive: true,
|
||||||
|
validFrom: { lte: now },
|
||||||
|
validUntil: { gte: now },
|
||||||
|
OR: [
|
||||||
|
{ maxUses: null }, // No limit
|
||||||
|
{ usedCount: { lt: prisma.coupon.fields.maxUses } }, // Has remaining uses
|
||||||
|
],
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter by minimum order amount if provided
|
||||||
|
let filteredCoupons = coupons;
|
||||||
|
if (orderAmount) {
|
||||||
|
filteredCoupons = coupons.filter(coupon => {
|
||||||
|
if (!coupon.minOrderAmount) return true;
|
||||||
|
return parseFloat(orderAmount) >= parseFloat(coupon.minOrderAmount);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add discount preview
|
||||||
|
const couponsWithPreview = filteredCoupons.map(coupon => {
|
||||||
|
let discountPreview = '';
|
||||||
|
|
||||||
|
if (coupon.type === 'PERCENTAGE') {
|
||||||
|
discountPreview = `${coupon.value}% OFF`;
|
||||||
|
} else if (coupon.type === 'FIXED_AMOUNT') {
|
||||||
|
discountPreview = `₹${coupon.value} OFF`;
|
||||||
|
} else if (coupon.type === 'FREE_SHIPPING') {
|
||||||
|
discountPreview = 'FREE SHIPPING';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: coupon.code,
|
||||||
|
description: coupon.description,
|
||||||
|
type: coupon.type,
|
||||||
|
value: parseFloat(coupon.value),
|
||||||
|
minOrderAmount: coupon.minOrderAmount ? parseFloat(coupon.minOrderAmount) : null,
|
||||||
|
validUntil: coupon.validUntil,
|
||||||
|
discountPreview,
|
||||||
|
remainingUses: coupon.maxUses ? coupon.maxUses - coupon.usedCount : null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
status: true,
|
||||||
|
message: 'Available coupons fetched successfully',
|
||||||
|
data: couponsWithPreview,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @desc Apply coupon to order
|
||||||
|
* @route POST /api/coupons/apply
|
||||||
|
* @access Private
|
||||||
|
*/
|
||||||
|
exports.applyCouponToOrder = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { couponCode, orderId } = req.body;
|
||||||
|
const userId = req.user.id;
|
||||||
|
|
||||||
|
// Get order
|
||||||
|
const order = await prisma.order.findUnique({
|
||||||
|
where: { id: orderId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!order) {
|
||||||
|
return res.status(404).json({
|
||||||
|
status: false,
|
||||||
|
message: 'Order not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify order belongs to user
|
||||||
|
if (order.userId !== userId) {
|
||||||
|
return res.status(403).json({
|
||||||
|
status: false,
|
||||||
|
message: 'Unauthorized',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if order already has a coupon
|
||||||
|
if (order.discountAmount > 0) {
|
||||||
|
return res.status(400).json({
|
||||||
|
status: false,
|
||||||
|
message: 'Order already has a discount applied',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get coupon
|
||||||
|
const coupon = await prisma.coupon.findUnique({
|
||||||
|
where: { code: couponCode.toUpperCase() },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!coupon) {
|
||||||
|
return res.status(404).json({
|
||||||
|
status: false,
|
||||||
|
message: 'Invalid coupon code',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate coupon
|
||||||
|
const orderAmount = parseFloat(order.subtotal);
|
||||||
|
const validation = validateCouponRules(coupon, orderAmount);
|
||||||
|
|
||||||
|
if (!validation.isValid) {
|
||||||
|
return res.status(400).json({
|
||||||
|
status: false,
|
||||||
|
message: validation.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate discount
|
||||||
|
let discountAmount = calculateDiscount(coupon, orderAmount);
|
||||||
|
let shippingAmount = parseFloat(order.shippingAmount);
|
||||||
|
|
||||||
|
// If free shipping coupon, set shipping to 0
|
||||||
|
if (coupon.type === 'FREE_SHIPPING') {
|
||||||
|
discountAmount = shippingAmount;
|
||||||
|
shippingAmount = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recalculate total
|
||||||
|
const newTotal = orderAmount + parseFloat(order.taxAmount) + shippingAmount - discountAmount;
|
||||||
|
|
||||||
|
// Update order
|
||||||
|
const updatedOrder = await prisma.$transaction([
|
||||||
|
// Update order
|
||||||
|
prisma.order.update({
|
||||||
|
where: { id: orderId },
|
||||||
|
data: {
|
||||||
|
discountAmount,
|
||||||
|
shippingAmount,
|
||||||
|
totalAmount: newTotal,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
// Increment coupon usage
|
||||||
|
prisma.coupon.update({
|
||||||
|
where: { id: coupon.id },
|
||||||
|
data: {
|
||||||
|
usedCount: { increment: 1 },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
status: true,
|
||||||
|
message: 'Coupon applied successfully',
|
||||||
|
data: updatedOrder[0],
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Apply coupon error:', error);
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @desc Remove coupon from order
|
||||||
|
* @route POST /api/coupons/remove
|
||||||
|
* @access Private
|
||||||
|
*/
|
||||||
|
exports.removeCouponFromOrder = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { orderId } = req.body;
|
||||||
|
const userId = req.user.id;
|
||||||
|
|
||||||
|
const order = await prisma.order.findUnique({
|
||||||
|
where: { id: orderId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!order) {
|
||||||
|
return res.status(404).json({
|
||||||
|
status: false,
|
||||||
|
message: 'Order not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (order.userId !== userId) {
|
||||||
|
return res.status(403).json({
|
||||||
|
status: false,
|
||||||
|
message: 'Unauthorized',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (order.discountAmount === 0) {
|
||||||
|
return res.status(400).json({
|
||||||
|
status: false,
|
||||||
|
message: 'No coupon applied to this order',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recalculate total without discount
|
||||||
|
const newTotal =
|
||||||
|
parseFloat(order.subtotal) +
|
||||||
|
parseFloat(order.taxAmount) +
|
||||||
|
parseFloat(order.shippingAmount);
|
||||||
|
|
||||||
|
const updatedOrder = await prisma.order.update({
|
||||||
|
where: { id: orderId },
|
||||||
|
data: {
|
||||||
|
discountAmount: 0,
|
||||||
|
totalAmount: newTotal,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
status: true,
|
||||||
|
message: 'Coupon removed successfully',
|
||||||
|
data: updatedOrder,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// HELPER FUNCTIONS
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate coupon rules
|
||||||
|
*/
|
||||||
|
function validateCouponRules(coupon, orderAmount) {
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
// Check if active
|
||||||
|
if (!coupon.isActive) {
|
||||||
|
return { isValid: false, message: 'This coupon is no longer active' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check date range
|
||||||
|
if (now < new Date(coupon.validFrom)) {
|
||||||
|
return { isValid: false, message: 'This coupon is not yet valid' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (now > new Date(coupon.validUntil)) {
|
||||||
|
return { isValid: false, message: 'This coupon has expired' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check usage limit
|
||||||
|
if (coupon.maxUses && coupon.usedCount >= coupon.maxUses) {
|
||||||
|
return { isValid: false, message: 'This coupon has reached its usage limit' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check minimum order amount
|
||||||
|
if (coupon.minOrderAmount && orderAmount < parseFloat(coupon.minOrderAmount)) {
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
message: `Minimum order amount of ₹${coupon.minOrderAmount} required`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isValid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate discount amount
|
||||||
|
*/
|
||||||
|
function calculateDiscount(coupon, orderAmount) {
|
||||||
|
if (coupon.type === 'PERCENTAGE') {
|
||||||
|
return (orderAmount * parseFloat(coupon.value)) / 100;
|
||||||
|
} else if (coupon.type === 'FIXED_AMOUNT') {
|
||||||
|
return Math.min(parseFloat(coupon.value), orderAmount);
|
||||||
|
} else if (coupon.type === 'FREE_SHIPPING') {
|
||||||
|
return 0; // Handled separately in order
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
822
src/controllers/orderController.js
Normal file
822
src/controllers/orderController.js
Normal file
@@ -0,0 +1,822 @@
|
|||||||
|
const { prisma } = require('../config/database');
|
||||||
|
const Product = require('../models/mongodb/Product');
|
||||||
|
const {
|
||||||
|
RETURN_WINDOW_DAYS,
|
||||||
|
ALLOWED_STATUSES,
|
||||||
|
} = require('../config/returnPolicy');
|
||||||
|
const {
|
||||||
|
calculateDeliveryDate,
|
||||||
|
} = require('../services/deliveryEstimationService');
|
||||||
|
const { reduceStockOnDelivery } = require('../services/inventoryService');
|
||||||
|
|
||||||
|
// @desc Create new order
|
||||||
|
// @route POST /api/orders
|
||||||
|
// @access Private
|
||||||
|
exports.createOrder = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { items, shippingAddressId, paymentMethod, couponCode } = req.body;
|
||||||
|
const userId = req.user.id;
|
||||||
|
console.log('=================================');
|
||||||
|
console.log('🎟️ COUPON DEBUG');
|
||||||
|
console.log('Received couponCode:', req.body.couponCode);
|
||||||
|
console.log('Full body:', req.body);
|
||||||
|
console.log('=================================');
|
||||||
|
console.log('📦 Creating order for user:', userId);
|
||||||
|
console.log('🎟️ Coupon code:', couponCode);
|
||||||
|
|
||||||
|
// Validate items
|
||||||
|
if (!items || items.length === 0) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'No items in the order',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate shipping address
|
||||||
|
const address = await prisma.address.findUnique({
|
||||||
|
where: { id: shippingAddressId },
|
||||||
|
});
|
||||||
|
const deliveryEstimation = calculateDeliveryDate(
|
||||||
|
address.postalCode,
|
||||||
|
new Date(),
|
||||||
|
'STANDARD'
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
'📅 Estimated delivery:',
|
||||||
|
deliveryEstimation.estimatedDelivery.formatted
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!address || address.userId !== userId) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Invalid shipping address',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch product details from MongoDB
|
||||||
|
const productIds = items.map(item => item.productId);
|
||||||
|
const products = await Product.find({ _id: { $in: productIds } });
|
||||||
|
|
||||||
|
if (products.length !== productIds.length) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Some products not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate totals
|
||||||
|
let subtotal = 0;
|
||||||
|
const orderItems = [];
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const product = products.find(p => p._id.toString() === item.productId);
|
||||||
|
|
||||||
|
if (!product) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: `Product ${item.productId} not found`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let price = product.basePrice;
|
||||||
|
let sku = product.slug;
|
||||||
|
|
||||||
|
if (product.hasVariants && product.variants?.length > 0) {
|
||||||
|
const variant = product.variants.find(v => v.sku === item.sku);
|
||||||
|
if (variant) {
|
||||||
|
price = variant.price;
|
||||||
|
sku = variant.sku;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemTotal = price * item.quantity;
|
||||||
|
subtotal += itemTotal;
|
||||||
|
|
||||||
|
orderItems.push({
|
||||||
|
productId: item.productId,
|
||||||
|
productName: product.name,
|
||||||
|
productSku: sku,
|
||||||
|
price: price,
|
||||||
|
quantity: item.quantity,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate tax and shipping
|
||||||
|
const taxRate = 0.18; // 18% GST
|
||||||
|
const taxAmount = subtotal * taxRate;
|
||||||
|
let shippingAmount = subtotal > 500 ? 0 : 50; // Free shipping above ₹500
|
||||||
|
let discountAmount = 0;
|
||||||
|
let appliedCoupon = null;
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// VALIDATE AND APPLY COUPON
|
||||||
|
// ==========================================
|
||||||
|
if (couponCode) {
|
||||||
|
console.log('🎟️ Validating coupon:', couponCode);
|
||||||
|
|
||||||
|
const coupon = await prisma.coupon.findUnique({
|
||||||
|
where: { code: couponCode.toUpperCase() },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (coupon) {
|
||||||
|
// Validate coupon
|
||||||
|
const now = new Date();
|
||||||
|
let couponError = null;
|
||||||
|
|
||||||
|
if (!coupon.isActive) {
|
||||||
|
couponError = 'Coupon is not active';
|
||||||
|
} else if (now < new Date(coupon.validFrom)) {
|
||||||
|
couponError = 'Coupon is not yet valid';
|
||||||
|
} else if (now > new Date(coupon.validUntil)) {
|
||||||
|
couponError = 'Coupon has expired';
|
||||||
|
} else if (coupon.maxUses && coupon.usedCount >= coupon.maxUses) {
|
||||||
|
couponError = 'Coupon usage limit reached';
|
||||||
|
} else if (
|
||||||
|
coupon.minOrderAmount &&
|
||||||
|
subtotal < parseFloat(coupon.minOrderAmount)
|
||||||
|
) {
|
||||||
|
couponError = `Minimum order amount of ₹${coupon.minOrderAmount} required`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (couponError) {
|
||||||
|
console.log('❌ Coupon validation failed:', couponError);
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: couponError,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate discount
|
||||||
|
if (coupon.type === 'PERCENTAGE') {
|
||||||
|
discountAmount = (subtotal * parseFloat(coupon.value)) / 100;
|
||||||
|
} else if (coupon.type === 'FIXED_AMOUNT') {
|
||||||
|
discountAmount = Math.min(parseFloat(coupon.value), subtotal);
|
||||||
|
} else if (coupon.type === 'FREE_SHIPPING') {
|
||||||
|
discountAmount = shippingAmount;
|
||||||
|
shippingAmount = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
appliedCoupon = coupon;
|
||||||
|
console.log('✅ Coupon applied:', {
|
||||||
|
code: coupon.code,
|
||||||
|
type: coupon.type,
|
||||||
|
discount: discountAmount,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.log('❌ Coupon not found');
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Invalid coupon code',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate final total
|
||||||
|
const totalAmount = subtotal + taxAmount + shippingAmount - discountAmount;
|
||||||
|
|
||||||
|
// Generate unique order number
|
||||||
|
const orderNumber = `ORD${Date.now()}${Math.floor(Math.random() * 1000)}`;
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// CREATE ORDER IN TRANSACTION
|
||||||
|
// ==========================================
|
||||||
|
const result = await prisma.$transaction(async tx => {
|
||||||
|
// Create order
|
||||||
|
const order = await tx.order.create({
|
||||||
|
data: {
|
||||||
|
orderNumber,
|
||||||
|
userId,
|
||||||
|
status: 'PENDING',
|
||||||
|
subtotal,
|
||||||
|
taxAmount,
|
||||||
|
shippingAmount,
|
||||||
|
discountAmount,
|
||||||
|
totalAmount,
|
||||||
|
paymentStatus: 'PENDING',
|
||||||
|
paymentMethod: paymentMethod || 'PAYTM',
|
||||||
|
shippingAddressId,
|
||||||
|
items: {
|
||||||
|
create: orderItems,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
items: true,
|
||||||
|
address: true,
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
phone: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ✅ INCREMENT COUPON USAGE COUNT
|
||||||
|
if (appliedCoupon) {
|
||||||
|
await tx.coupon.update({
|
||||||
|
where: { id: appliedCoupon.id },
|
||||||
|
data: {
|
||||||
|
usedCount: {
|
||||||
|
increment: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ Coupon usage incremented:', {
|
||||||
|
code: appliedCoupon.code,
|
||||||
|
previousCount: appliedCoupon.usedCount,
|
||||||
|
newCount: appliedCoupon.usedCount + 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return order;
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ Order created:', result.id);
|
||||||
|
|
||||||
|
// Clear user's cart after order creation
|
||||||
|
await prisma.cartItem.deleteMany({
|
||||||
|
where: { userId },
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Order created successfully',
|
||||||
|
order: result,
|
||||||
|
appliedCoupon: appliedCoupon
|
||||||
|
? {
|
||||||
|
code: appliedCoupon.code,
|
||||||
|
discount: discountAmount,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
deliveryEstimation,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Create order error:', error);
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// @desc Get user orders
|
||||||
|
exports.getUserOrders = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { page = 1, limit = 10, status } = req.query;
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
const where = { userId: req.user.id };
|
||||||
|
if (status) where.status = status;
|
||||||
|
|
||||||
|
const [orders, total] = await Promise.all([
|
||||||
|
prisma.order.findMany({
|
||||||
|
where,
|
||||||
|
include: { items: true, address: true },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
skip: parseInt(skip),
|
||||||
|
take: parseInt(limit),
|
||||||
|
}),
|
||||||
|
prisma.order.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// res.json({
|
||||||
|
// success: true,
|
||||||
|
// data: {
|
||||||
|
// orders,
|
||||||
|
// pagination: {
|
||||||
|
// page: parseInt(page),
|
||||||
|
// limit: parseInt(limit),
|
||||||
|
// total,
|
||||||
|
// pages: Math.ceil(total / limit),
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
statusCode: 200,
|
||||||
|
status: true,
|
||||||
|
message: 'Orders fetched successfully',
|
||||||
|
data: {
|
||||||
|
orders,
|
||||||
|
pagination: {
|
||||||
|
page: parseInt(page),
|
||||||
|
limit: parseInt(limit),
|
||||||
|
total,
|
||||||
|
pages: Math.ceil(total / limit),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// @desc Get single order
|
||||||
|
exports.getOrderById = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const order = await prisma.order.findFirst({
|
||||||
|
where: { id: req.params.id, userId: req.user.id },
|
||||||
|
include: { items: true, address: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!order) {
|
||||||
|
return res.status(404).json({
|
||||||
|
// statusCode: 404,
|
||||||
|
status: false,
|
||||||
|
message: 'Order not found',
|
||||||
|
data: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2️⃣ Collect productIds
|
||||||
|
const productIds = order.items.map(item => item.productId);
|
||||||
|
|
||||||
|
// 3️⃣ Fetch products from MongoDB
|
||||||
|
const products = await Product.find({
|
||||||
|
_id: { $in: productIds },
|
||||||
|
}).select('name images');
|
||||||
|
|
||||||
|
// 4️⃣ Convert products array to map for quick lookup
|
||||||
|
const productMap = {};
|
||||||
|
products.forEach(product => {
|
||||||
|
productMap[product._id.toString()] = product;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5️⃣ Attach product image to each order item
|
||||||
|
const updatedItems = order.items.map(item => {
|
||||||
|
const product = productMap[item.productId];
|
||||||
|
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
productImage: product?.images?.primary || null,
|
||||||
|
productGallery: product?.images?.gallery || [],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
order.items = updatedItems;
|
||||||
|
|
||||||
|
// res.json({ success: true, data: { order } });
|
||||||
|
return res.status(200).json({
|
||||||
|
statusCode: 200,
|
||||||
|
status: true,
|
||||||
|
message: 'Order fetched successfully',
|
||||||
|
data: { order },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// @desc Update order status (Admin)
|
||||||
|
exports.updateOrderStatus = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { status, trackingNumber } = req.body;
|
||||||
|
// if (!status)
|
||||||
|
// return res
|
||||||
|
// .status(400)
|
||||||
|
// .json({ success: false, message: 'Status is required' });
|
||||||
|
|
||||||
|
if (!status) {
|
||||||
|
return res.status(400).json({
|
||||||
|
statusCode: 400,
|
||||||
|
status: false,
|
||||||
|
message: 'Status is required',
|
||||||
|
data: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const order = await prisma.order.findUnique({
|
||||||
|
where: { id: req.params.id },
|
||||||
|
});
|
||||||
|
// if (!order)
|
||||||
|
// return res
|
||||||
|
// .status(404)
|
||||||
|
// .json({ success: false, message: 'Order not found' });
|
||||||
|
|
||||||
|
if (!order) {
|
||||||
|
return res.status(404).json({
|
||||||
|
statusCode: 404,
|
||||||
|
status: false,
|
||||||
|
message: 'Order not found',
|
||||||
|
data: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateData = { status };
|
||||||
|
if (trackingNumber) updateData.trackingNumber = trackingNumber;
|
||||||
|
if (status === 'SHIPPED') updateData.shippedAt = new Date();
|
||||||
|
if (status === 'DELIVERED') updateData.deliveredAt = new Date();
|
||||||
|
|
||||||
|
const updatedOrder = await prisma.order.update({
|
||||||
|
where: { id: req.params.id },
|
||||||
|
data: updateData,
|
||||||
|
include: { items: true, address: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// res.json({
|
||||||
|
// success: true,
|
||||||
|
// message: 'Order status updated',
|
||||||
|
// data: { order: updatedOrder },
|
||||||
|
// });
|
||||||
|
return res.status(200).json({
|
||||||
|
statusCode: 200,
|
||||||
|
status: true,
|
||||||
|
message: 'Order status updated successfully',
|
||||||
|
data: { order: updatedOrder },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// @desc Cancel order
|
||||||
|
exports.cancelOrder = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const order = await prisma.order.findFirst({
|
||||||
|
where: { id: req.params.id, userId: req.user.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
// if (!order)
|
||||||
|
// return res
|
||||||
|
// .status(404)
|
||||||
|
// .json({ success: false, message: 'Order not found' });
|
||||||
|
if (!order) {
|
||||||
|
return res.status(404).json({
|
||||||
|
statusCode: 404,
|
||||||
|
status: false,
|
||||||
|
message: 'Order not found',
|
||||||
|
data: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// if (!['PENDING', 'CONFIRMED'].includes(order.status))
|
||||||
|
// return res
|
||||||
|
// .status(400)
|
||||||
|
// .json({ success: false, message: 'Order cannot be cancelled' });
|
||||||
|
|
||||||
|
if (!['PENDING', 'CONFIRMED'].includes(order.status)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
statusCode: 400,
|
||||||
|
status: false,
|
||||||
|
message: 'Order cannot be cancelled',
|
||||||
|
data: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedOrder = await prisma.order.update({
|
||||||
|
where: { id: req.params.id },
|
||||||
|
data: { status: 'CANCELLED' },
|
||||||
|
include: { items: true, address: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// res.json({
|
||||||
|
// success: true,
|
||||||
|
// message: 'Order cancelled',
|
||||||
|
// data: { order: updatedOrder },
|
||||||
|
// });
|
||||||
|
return res.status(200).json({
|
||||||
|
statusCode: 200,
|
||||||
|
status: true,
|
||||||
|
message: 'Order cancelled successfully',
|
||||||
|
data: { order: updatedOrder },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// exports.returnOrder = async (req, res, next) => {
|
||||||
|
// try {
|
||||||
|
// const order = await prisma.order.findFirst({
|
||||||
|
// where: {
|
||||||
|
// id: req.params.id,
|
||||||
|
// userId: req.user.id,
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
|
||||||
|
// if (!order) {
|
||||||
|
// return res.status(404).json({
|
||||||
|
// status: false,
|
||||||
|
// message: 'Order not found',
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // ✅ RETURN STATUS CHECK (YOUR CODE GOES HERE)
|
||||||
|
// if (!ALLOWED_STATUSES.includes(order.status)) {
|
||||||
|
// return res.status(400).json({
|
||||||
|
// status: false,
|
||||||
|
// message: 'Order not eligible for return',
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // ✅ RETURN WINDOW CHECK
|
||||||
|
// const deliveredAt = order.deliveredAt || order.updatedAt;
|
||||||
|
// const diffDays =
|
||||||
|
// (Date.now() - new Date(deliveredAt)) / (1000 * 60 * 60 * 24);
|
||||||
|
|
||||||
|
// if (diffDays > RETURN_WINDOW_DAYS) {
|
||||||
|
// return res.status(400).json({
|
||||||
|
// status: false,
|
||||||
|
// message: `Return allowed within ${RETURN_WINDOW_DAYS} days only`,
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // ✅ UPDATE ORDER
|
||||||
|
// const updatedOrder = await prisma.order.update({
|
||||||
|
// where: { id: order.id },
|
||||||
|
// data: {
|
||||||
|
// status: 'RETURN_REQUESTED',
|
||||||
|
// returnRequestedAt: new Date(),
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
|
||||||
|
// return res.status(200).json({
|
||||||
|
// status: true,
|
||||||
|
// message: 'Return request submitted successfully',
|
||||||
|
// data: { order: updatedOrder },
|
||||||
|
// });
|
||||||
|
// } catch (error) {
|
||||||
|
// next(error);
|
||||||
|
// }
|
||||||
|
// };
|
||||||
|
|
||||||
|
// @desc Return order
|
||||||
|
// @route PUT /api/orders/:id/return
|
||||||
|
// @access Private
|
||||||
|
exports.returnOrder = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
// Find the order belonging to the logged-in user
|
||||||
|
const order = await prisma.order.findFirst({
|
||||||
|
where: {
|
||||||
|
id: req.params.id,
|
||||||
|
userId: req.user.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!order) {
|
||||||
|
return res.status(404).json({
|
||||||
|
status: false,
|
||||||
|
message: 'Order not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Check if order status allows return
|
||||||
|
const ALLOWED_STATUSES = ['DELIVERED']; // only delivered orders can be returned
|
||||||
|
if (!ALLOWED_STATUSES.includes(order.status)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
status: false,
|
||||||
|
message: 'Order not eligible for return',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Check return window (e.g., 7 days)
|
||||||
|
const RETURN_WINDOW_DAYS = 7;
|
||||||
|
const deliveredAt = order.deliveredAt || order.updatedAt;
|
||||||
|
const diffDays =
|
||||||
|
(Date.now() - new Date(deliveredAt)) / (1000 * 60 * 60 * 24);
|
||||||
|
|
||||||
|
if (diffDays > RETURN_WINDOW_DAYS) {
|
||||||
|
return res.status(400).json({
|
||||||
|
status: false,
|
||||||
|
message: `Return allowed within ${RETURN_WINDOW_DAYS} days only`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Update order: set both status and returnStatus
|
||||||
|
const updatedOrder = await prisma.order.update({
|
||||||
|
where: { id: order.id },
|
||||||
|
data: {
|
||||||
|
status: 'RETURN_REQUESTED', // OrderStatus
|
||||||
|
returnStatus: 'REQUESTED', // ReturnStatus (admin will use this)
|
||||||
|
returnRequestedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
status: true,
|
||||||
|
message: 'Return request submitted successfully',
|
||||||
|
data: { order: updatedOrder },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// @desc Get all orders (Admin)
|
||||||
|
exports.getAllOrdersAdmin = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { page = 1, limit = 20, status, paymentStatus } = req.query;
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
|
const where = {};
|
||||||
|
if (status) where.status = status;
|
||||||
|
if (paymentStatus) where.paymentStatus = paymentStatus;
|
||||||
|
|
||||||
|
const [orders, total] = await Promise.all([
|
||||||
|
prisma.order.findMany({
|
||||||
|
where,
|
||||||
|
include: {
|
||||||
|
items: true,
|
||||||
|
address: true,
|
||||||
|
user: {
|
||||||
|
select: { id: true, email: true, firstName: true, lastName: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
skip: parseInt(skip),
|
||||||
|
take: parseInt(limit),
|
||||||
|
}),
|
||||||
|
prisma.order.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// res.json({
|
||||||
|
// success: true,
|
||||||
|
// data: {
|
||||||
|
// orders,
|
||||||
|
// pagination: {
|
||||||
|
// page: parseInt(page),
|
||||||
|
// limit: parseInt(limit),
|
||||||
|
// total,
|
||||||
|
// pages: Math.ceil(total / limit),
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
return res.status(200).json({
|
||||||
|
statusCode: 200,
|
||||||
|
status: true,
|
||||||
|
message: 'Orders fetched successfully',
|
||||||
|
data: {
|
||||||
|
orders,
|
||||||
|
pagination: {
|
||||||
|
page: parseInt(page),
|
||||||
|
limit: parseInt(limit),
|
||||||
|
total,
|
||||||
|
pages: Math.ceil(total / limit),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// @desc Approve or reject a return request
|
||||||
|
// @route PUT /api/orders/:id/return/status
|
||||||
|
// @access Private/Admin
|
||||||
|
exports.updateReturnStatus = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { action } = req.body; // "APPROVE" or "REJECT"
|
||||||
|
|
||||||
|
const order = await prisma.order.findUnique({ where: { id } });
|
||||||
|
|
||||||
|
if (!order) {
|
||||||
|
return res
|
||||||
|
.status(404)
|
||||||
|
.json({ status: false, message: 'Order not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (order.returnStatus !== 'REQUESTED') {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ status: false, message: 'No return request pending' });
|
||||||
|
}
|
||||||
|
|
||||||
|
let newStatus;
|
||||||
|
if (action === 'APPROVE') newStatus = 'APPROVED';
|
||||||
|
else if (action === 'REJECT') newStatus = 'REJECTED';
|
||||||
|
else
|
||||||
|
return res.status(400).json({ status: false, message: 'Invalid action' });
|
||||||
|
|
||||||
|
const updatedOrder = await prisma.order.update({
|
||||||
|
where: { id },
|
||||||
|
data: { returnStatus: newStatus },
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
status: true,
|
||||||
|
message: `Return request ${action.toLowerCase()}ed successfully`,
|
||||||
|
data: { order: updatedOrder },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// @desc Get all return requests (Admin only)
|
||||||
|
// @route GET /api/orders/admin/returns
|
||||||
|
// @access Private/Admin
|
||||||
|
exports.getAdminReturnRequests = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const page = Number(req.query.page) || 1;
|
||||||
|
const limit = Number(req.query.limit) || 10;
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
|
const [returns, count] = await Promise.all([
|
||||||
|
prisma.order.findMany({
|
||||||
|
where: {
|
||||||
|
returnStatus: {
|
||||||
|
in: ['REQUESTED', 'APPROVED', 'REJECTED', 'COMPLETED'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
user: true,
|
||||||
|
address: true,
|
||||||
|
items: true,
|
||||||
|
},
|
||||||
|
orderBy: { returnRequestedAt: 'desc' },
|
||||||
|
skip,
|
||||||
|
take: limit,
|
||||||
|
}),
|
||||||
|
prisma.order.count({
|
||||||
|
where: {
|
||||||
|
returnStatus: {
|
||||||
|
in: ['REQUESTED', 'APPROVED', 'REJECTED', 'COMPLETED'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
status: true,
|
||||||
|
count,
|
||||||
|
data: returns,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Admin return list error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
status: false,
|
||||||
|
message: 'Failed to fetch return requests',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// @desc Get all returned products (Admin only)
|
||||||
|
// @route GET /api/orders/admin/returns/list
|
||||||
|
// @access Private/Admin
|
||||||
|
exports.getReturnedProducts = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const returnedOrders = await prisma.order.findMany({
|
||||||
|
where: {
|
||||||
|
returnStatus: { in: ['APPROVED', 'COMPLETED'] },
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
user: true,
|
||||||
|
address: true,
|
||||||
|
items: true,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
returnRequestedAt: 'desc',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
status: true,
|
||||||
|
count: returnedOrders.length,
|
||||||
|
data: returnedOrders,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.getReturnRequestById = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
// Fetch the order with related user, address, and items
|
||||||
|
const order = await prisma.order.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
user: true,
|
||||||
|
address: true,
|
||||||
|
items: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!order) {
|
||||||
|
return res
|
||||||
|
.status(404)
|
||||||
|
.json({ status: false, message: 'Return request not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure this order is a return request
|
||||||
|
if (
|
||||||
|
!['RETURN_REQUESTED', 'APPROVED', 'REJECTED', 'COMPLETED'].includes(
|
||||||
|
order.returnStatus
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ status: false, message: 'This order is not a return request' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ status: true, data: order });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching return request:', error);
|
||||||
|
res.status(500).json({ status: false, message: 'Server error' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// module.exports = { getReturnRequestById };
|
||||||
719
src/controllers/orderTrackingController.js
Normal file
719
src/controllers/orderTrackingController.js
Normal file
@@ -0,0 +1,719 @@
|
|||||||
|
// controllers/orderTrackingController.js
|
||||||
|
|
||||||
|
const { prisma } = require('../config/database');
|
||||||
|
const {
|
||||||
|
calculateDeliveryDate,
|
||||||
|
getDeliveryEstimation,
|
||||||
|
} = require('../services/deliveryEstimationService');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @desc Get order tracking details
|
||||||
|
* @route GET /api/orders/:orderId/tracking
|
||||||
|
* @access Private
|
||||||
|
*/
|
||||||
|
exports.getOrderTracking = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { orderId } = req.params;
|
||||||
|
const userId = req.user.id;
|
||||||
|
|
||||||
|
// Get order with all details
|
||||||
|
const order = await prisma.order.findUnique({
|
||||||
|
where: { id: orderId },
|
||||||
|
include: {
|
||||||
|
items: true,
|
||||||
|
address: true,
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
phone: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!order) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Order not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user owns this order
|
||||||
|
if (order.userId !== userId) {
|
||||||
|
return res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Unauthorized to view this order',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate delivery estimation
|
||||||
|
const deliveryEstimation = await getDeliveryEstimation(
|
||||||
|
order.address.postalCode,
|
||||||
|
'STANDARD'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get order timeline/history
|
||||||
|
const timeline = generateOrderTimeline(order, deliveryEstimation.data);
|
||||||
|
|
||||||
|
// Get current status details
|
||||||
|
const statusDetails = getStatusDetails(order);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
order: {
|
||||||
|
id: order.id,
|
||||||
|
orderNumber: order.orderNumber,
|
||||||
|
status: order.status,
|
||||||
|
paymentStatus: order.paymentStatus,
|
||||||
|
totalAmount: parseFloat(order.totalAmount),
|
||||||
|
createdAt: order.createdAt,
|
||||||
|
},
|
||||||
|
tracking: {
|
||||||
|
currentStatus: statusDetails,
|
||||||
|
timeline,
|
||||||
|
deliveryEstimation: deliveryEstimation.data,
|
||||||
|
trackingNumber: order.trackingNumber,
|
||||||
|
},
|
||||||
|
shippingAddress: {
|
||||||
|
name: `${order.address.firstName} ${order.address.lastName}`,
|
||||||
|
addressLine1: order.address.addressLine1,
|
||||||
|
addressLine2: order.address.addressLine2,
|
||||||
|
city: order.address.city,
|
||||||
|
state: order.address.state,
|
||||||
|
postalCode: order.address.postalCode,
|
||||||
|
country: order.address.country,
|
||||||
|
phone: order.address.phone,
|
||||||
|
},
|
||||||
|
items: order.items.map(item => ({
|
||||||
|
productName: item.productName,
|
||||||
|
productSku: item.productSku,
|
||||||
|
quantity: item.quantity,
|
||||||
|
price: parseFloat(item.price),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get order tracking error:', error);
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @desc Update order status (Admin) - FLEXIBLE VERSION
|
||||||
|
* @route PUT /api/admin/orders/:orderId/status
|
||||||
|
* @access Private/Admin
|
||||||
|
*/
|
||||||
|
// exports.updateOrderStatus = async (req, res, next) => {
|
||||||
|
// try {
|
||||||
|
// const { orderId } = req.params;
|
||||||
|
// const { status, trackingNumber, notes, skipValidation = false } = req.body;
|
||||||
|
|
||||||
|
// console.log('📦 Updating order status:', {
|
||||||
|
// orderId,
|
||||||
|
// currentStatus: 'Fetching...',
|
||||||
|
// newStatus: status,
|
||||||
|
// trackingNumber,
|
||||||
|
// });
|
||||||
|
|
||||||
|
// const order = await prisma.order.findUnique({
|
||||||
|
// where: { id: orderId },
|
||||||
|
// });
|
||||||
|
|
||||||
|
// if (!order) {
|
||||||
|
// return res.status(404).json({
|
||||||
|
// success: false,
|
||||||
|
// message: 'Order not found',
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
// console.log('📦 Current order status:', order.status);
|
||||||
|
|
||||||
|
// // ✅ FLEXIBLE VALIDATION - Allow skipping intermediate steps
|
||||||
|
// const validTransitions = {
|
||||||
|
// PENDING: ['CONFIRMED', 'PROCESSING', 'SHIPPED', 'CANCELLED'],
|
||||||
|
// CONFIRMED: ['PROCESSING', 'SHIPPED', 'CANCELLED'],
|
||||||
|
// PROCESSING: ['SHIPPED', 'DELIVERED', 'CANCELLED'],
|
||||||
|
// SHIPPED: ['DELIVERED'],
|
||||||
|
// DELIVERED: ['RETURN_REQUESTED'],
|
||||||
|
// RETURN_REQUESTED: ['REFUNDED'],
|
||||||
|
// CANCELLED: [], // Cannot transition from cancelled
|
||||||
|
// REFUNDED: [], // Cannot transition from refunded
|
||||||
|
// };
|
||||||
|
|
||||||
|
// // Validate transition (unless skipValidation is true)
|
||||||
|
// if (!skipValidation && !validTransitions[order.status]?.includes(status)) {
|
||||||
|
// return res.status(400).json({
|
||||||
|
// success: false,
|
||||||
|
// message: `Cannot transition from ${order.status} to ${status}`,
|
||||||
|
// allowedTransitions: validTransitions[order.status],
|
||||||
|
// hint: 'You can set skipValidation: true to force this transition',
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // ✅ Auto-update intermediate statuses if needed
|
||||||
|
// let intermediateUpdates = [];
|
||||||
|
|
||||||
|
// if (order.status === 'PENDING' && status === 'SHIPPED') {
|
||||||
|
// intermediateUpdates = ['CONFIRMED', 'PROCESSING'];
|
||||||
|
// console.log(
|
||||||
|
// '⚡ Auto-updating intermediate statuses:',
|
||||||
|
// intermediateUpdates
|
||||||
|
// );
|
||||||
|
// } else if (order.status === 'CONFIRMED' && status === 'DELIVERED') {
|
||||||
|
// intermediateUpdates = ['PROCESSING', 'SHIPPED'];
|
||||||
|
// console.log(
|
||||||
|
// '⚡ Auto-updating intermediate statuses:',
|
||||||
|
// intermediateUpdates
|
||||||
|
// );
|
||||||
|
// } else if (order.status === 'PENDING' && status === 'DELIVERED') {
|
||||||
|
// intermediateUpdates = ['CONFIRMED', 'PROCESSING', 'SHIPPED'];
|
||||||
|
// console.log(
|
||||||
|
// '⚡ Auto-updating intermediate statuses:',
|
||||||
|
// intermediateUpdates
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // Build update data
|
||||||
|
// const updateData = { status };
|
||||||
|
|
||||||
|
// if (trackingNumber) {
|
||||||
|
// updateData.trackingNumber = trackingNumber;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// if (status === 'SHIPPED' && !order.shippedAt) {
|
||||||
|
// updateData.shippedAt = new Date();
|
||||||
|
// }
|
||||||
|
|
||||||
|
// if (status === 'DELIVERED' && !order.deliveredAt) {
|
||||||
|
// updateData.deliveredAt = new Date();
|
||||||
|
// updateData.shippedAt = updateData.shippedAt || new Date(); // Ensure shipped date is set
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // Update order
|
||||||
|
// const updatedOrder = await prisma.order.update({
|
||||||
|
// where: { id: orderId },
|
||||||
|
// data: updateData,
|
||||||
|
// });
|
||||||
|
|
||||||
|
// console.log('✅ Order status updated:', {
|
||||||
|
// from: order.status,
|
||||||
|
// to: updatedOrder.status,
|
||||||
|
// trackingNumber: updatedOrder.trackingNumber,
|
||||||
|
// });
|
||||||
|
|
||||||
|
// // TODO: Send notification to user
|
||||||
|
// // await sendOrderStatusNotification(updatedOrder);
|
||||||
|
|
||||||
|
// res.status(200).json({
|
||||||
|
// success: true,
|
||||||
|
// message: `Order status updated to ${status}`,
|
||||||
|
// data: updatedOrder,
|
||||||
|
// intermediateUpdates:
|
||||||
|
// intermediateUpdates.length > 0 ? intermediateUpdates : undefined,
|
||||||
|
// });
|
||||||
|
// } catch (error) {
|
||||||
|
// console.error('❌ Update order status error:', error);
|
||||||
|
// next(error);
|
||||||
|
// }
|
||||||
|
// };
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @desc Update order status (Admin) - ALL MANUAL
|
||||||
|
* @route PUT /api/admin/orders/:orderId/status
|
||||||
|
* @access Private/Admin
|
||||||
|
*/
|
||||||
|
// exports.updateOrderStatus = async (req, res, next) => {
|
||||||
|
// try {
|
||||||
|
// const { orderId } = req.params;
|
||||||
|
// const { status, trackingNumber, notes } = req.body;
|
||||||
|
|
||||||
|
// console.log('📦 Updating order status:', {
|
||||||
|
// orderId,
|
||||||
|
// newStatus: status,
|
||||||
|
// });
|
||||||
|
|
||||||
|
// const order = await prisma.order.findUnique({
|
||||||
|
// where: { id: orderId },
|
||||||
|
// });
|
||||||
|
|
||||||
|
// if (!order) {
|
||||||
|
// return res.status(404).json({
|
||||||
|
// success: false,
|
||||||
|
// message: 'Order not found',
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
// console.log('📦 Current order status:', order.status);
|
||||||
|
|
||||||
|
// // ✅ SIMPLE VALIDATION - Admin can update to any status
|
||||||
|
// const validStatuses = [
|
||||||
|
// 'PENDING',
|
||||||
|
// 'CONFIRMED',
|
||||||
|
// 'PROCESSING',
|
||||||
|
// 'SHIPPED',
|
||||||
|
// 'DELIVERED',
|
||||||
|
// 'CANCELLED',
|
||||||
|
// 'RETURN_REQUESTED',
|
||||||
|
// ];
|
||||||
|
|
||||||
|
// if (!validStatuses.includes(status)) {
|
||||||
|
// return res.status(400).json({
|
||||||
|
// success: false,
|
||||||
|
// message: `Invalid status: ${status}`,
|
||||||
|
// validStatuses,
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // Build update data
|
||||||
|
// const updateData = { status };
|
||||||
|
|
||||||
|
// if (trackingNumber) {
|
||||||
|
// updateData.trackingNumber = trackingNumber;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // Auto-set timestamps
|
||||||
|
// if (status === 'SHIPPED' && !order.shippedAt) {
|
||||||
|
// updateData.shippedAt = new Date();
|
||||||
|
// }
|
||||||
|
|
||||||
|
// if (status === 'DELIVERED' && !order.deliveredAt) {
|
||||||
|
// updateData.deliveredAt = new Date();
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // Update order
|
||||||
|
// const updatedOrder = await prisma.order.update({
|
||||||
|
// where: { id: orderId },
|
||||||
|
// data: updateData,
|
||||||
|
// });
|
||||||
|
|
||||||
|
// console.log('✅ Order status updated:', {
|
||||||
|
// from: order.status,
|
||||||
|
// to: updatedOrder.status,
|
||||||
|
// });
|
||||||
|
|
||||||
|
// res.status(200).json({
|
||||||
|
// success: true,
|
||||||
|
// message: `Order status updated to ${status}`,
|
||||||
|
// data: updatedOrder,
|
||||||
|
// });
|
||||||
|
// } catch (error) {
|
||||||
|
// console.error('❌ Update order status error:', error);
|
||||||
|
// next(error);
|
||||||
|
// }
|
||||||
|
// };
|
||||||
|
|
||||||
|
|
||||||
|
exports.updateOrderStatus = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { orderId } = req.params;
|
||||||
|
const { status, trackingNumber, notes } = req.body;
|
||||||
|
|
||||||
|
const adminId = req.user.id;
|
||||||
|
const ipAddress = req.ip || req.connection.remoteAddress;
|
||||||
|
const userAgent = req.get("user-agent");
|
||||||
|
|
||||||
|
console.log("📦 Updating order status:", {
|
||||||
|
orderId,
|
||||||
|
newStatus: status,
|
||||||
|
admin: adminId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const order = await prisma.order.findUnique({
|
||||||
|
where: { id: orderId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!order) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "Order not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldStatus = order.status;
|
||||||
|
|
||||||
|
const validStatuses = [
|
||||||
|
"PENDING",
|
||||||
|
"CONFIRMED",
|
||||||
|
"PROCESSING",
|
||||||
|
"SHIPPED",
|
||||||
|
"DELIVERED",
|
||||||
|
"CANCELLED",
|
||||||
|
"RETURN_REQUESTED",
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!validStatuses.includes(status)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: `Invalid status: ${status}`,
|
||||||
|
validStatuses,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateData = { status };
|
||||||
|
|
||||||
|
if (trackingNumber) updateData.trackingNumber = trackingNumber;
|
||||||
|
|
||||||
|
if (status === "SHIPPED" && !order.shippedAt) {
|
||||||
|
updateData.shippedAt = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === "DELIVERED" && !order.deliveredAt) {
|
||||||
|
updateData.deliveredAt = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ SINGLE CLEAN TRANSACTION
|
||||||
|
const result = await prisma.$transaction(async (tx) => {
|
||||||
|
const updatedOrder = await tx.order.update({
|
||||||
|
where: { id: orderId },
|
||||||
|
data: updateData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const historyRecord = await tx.orderStatusHistory.create({
|
||||||
|
data: {
|
||||||
|
orderId,
|
||||||
|
fromStatus: oldStatus,
|
||||||
|
toStatus: status,
|
||||||
|
changedBy: adminId,
|
||||||
|
trackingNumber: trackingNumber || null,
|
||||||
|
notes: notes || null,
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { order: updatedOrder, history: historyRecord };
|
||||||
|
});
|
||||||
|
|
||||||
|
// ✅ Auto stock reduction AFTER successful transaction
|
||||||
|
let stockReduction = null;
|
||||||
|
|
||||||
|
if (status === "DELIVERED" && oldStatus !== "DELIVERED") {
|
||||||
|
try {
|
||||||
|
stockReduction = await reduceStockOnDelivery(orderId);
|
||||||
|
console.log("✅ Stock reduced automatically");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("❌ Stock reduction failed:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("✅ Order status updated:", {
|
||||||
|
from: oldStatus,
|
||||||
|
to: result.order.status,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: `Order status updated from ${oldStatus} to ${status}`,
|
||||||
|
data: result.order,
|
||||||
|
stockReduction,
|
||||||
|
historyId: result.history.id,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ Update order status error:", error);
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @desc Get order status history
|
||||||
|
* @route GET /api/admin/orders/:orderId/history
|
||||||
|
* @access Private/Admin
|
||||||
|
*/
|
||||||
|
exports.getOrderStatusHistory = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { orderId } = req.params;
|
||||||
|
|
||||||
|
const history = await prisma.orderStatusHistory.findMany({
|
||||||
|
where: { orderId },
|
||||||
|
include: {
|
||||||
|
admin: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: history,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Get status history error:', error);
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @desc Bulk update order status (Admin)
|
||||||
|
* @route PUT /api/admin/orders/bulk-status
|
||||||
|
* @access Private/Admin
|
||||||
|
*/
|
||||||
|
exports.bulkUpdateOrderStatus = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { orderIds, status, trackingNumbers } = req.body;
|
||||||
|
|
||||||
|
if (!orderIds || !Array.isArray(orderIds) || orderIds.length === 0) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Order IDs array is required',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateData = { status };
|
||||||
|
|
||||||
|
if (status === 'SHIPPED') {
|
||||||
|
updateData.shippedAt = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'DELIVERED') {
|
||||||
|
updateData.deliveredAt = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update all orders
|
||||||
|
const updates = await Promise.all(
|
||||||
|
orderIds.map(async (orderId, index) => {
|
||||||
|
const data = { ...updateData };
|
||||||
|
|
||||||
|
if (trackingNumbers && trackingNumbers[index]) {
|
||||||
|
data.trackingNumber = trackingNumbers[index];
|
||||||
|
}
|
||||||
|
|
||||||
|
return prisma.order.update({
|
||||||
|
where: { id: orderId },
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: `${updates.length} orders updated to ${status}`,
|
||||||
|
data: updates,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Bulk update error:', error);
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @desc Get delivery estimation for pincode
|
||||||
|
* @route POST /api/delivery/estimate
|
||||||
|
* @access Public
|
||||||
|
*/
|
||||||
|
exports.getDeliveryEstimate = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { pincode, shippingMethod = 'STANDARD' } = req.body;
|
||||||
|
|
||||||
|
if (!pincode) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Pincode is required',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const estimation = await getDeliveryEstimation(pincode, shippingMethod);
|
||||||
|
|
||||||
|
res.status(200).json(estimation);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get delivery estimate error:', error);
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// HELPER FUNCTIONS
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate order timeline
|
||||||
|
*/
|
||||||
|
function generateOrderTimeline(order, deliveryEstimation) {
|
||||||
|
const timeline = [];
|
||||||
|
|
||||||
|
timeline.push({
|
||||||
|
status: 'PLACED',
|
||||||
|
title: 'Order Placed',
|
||||||
|
description: 'Your order has been placed successfully',
|
||||||
|
timestamp: order.createdAt,
|
||||||
|
completed: true,
|
||||||
|
icon: '🛒',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (order.status !== 'PENDING') {
|
||||||
|
timeline.push({
|
||||||
|
status: 'CONFIRMED',
|
||||||
|
title: 'Order Confirmed',
|
||||||
|
description: 'Your order has been confirmed',
|
||||||
|
timestamp: order.createdAt,
|
||||||
|
completed: true,
|
||||||
|
icon: '✅',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
timeline.push({
|
||||||
|
status: 'CONFIRMED',
|
||||||
|
title: 'Order Confirmation',
|
||||||
|
description: 'Awaiting order confirmation',
|
||||||
|
completed: false,
|
||||||
|
icon: '⏳',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['PROCESSING', 'SHIPPED', 'DELIVERED'].includes(order.status)) {
|
||||||
|
timeline.push({
|
||||||
|
status: 'PROCESSING',
|
||||||
|
title: 'Processing',
|
||||||
|
description: 'Your order is being processed',
|
||||||
|
timestamp: order.createdAt,
|
||||||
|
completed: true,
|
||||||
|
icon: '📦',
|
||||||
|
});
|
||||||
|
} else if (order.status === 'CONFIRMED') {
|
||||||
|
timeline.push({
|
||||||
|
status: 'PROCESSING',
|
||||||
|
title: 'Processing',
|
||||||
|
description: 'Order will be processed soon',
|
||||||
|
completed: false,
|
||||||
|
icon: '⏳',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['SHIPPED', 'DELIVERED'].includes(order.status)) {
|
||||||
|
timeline.push({
|
||||||
|
status: 'SHIPPED',
|
||||||
|
title: 'Shipped',
|
||||||
|
description: order.trackingNumber
|
||||||
|
? `Tracking: ${order.trackingNumber}`
|
||||||
|
: 'Your order has been shipped',
|
||||||
|
timestamp: order.shippedAt || new Date(),
|
||||||
|
completed: true,
|
||||||
|
icon: '🚚',
|
||||||
|
});
|
||||||
|
} else if (['CONFIRMED', 'PROCESSING'].includes(order.status)) {
|
||||||
|
timeline.push({
|
||||||
|
status: 'SHIPPED',
|
||||||
|
title: 'Shipping',
|
||||||
|
description: 'Your order will be shipped soon',
|
||||||
|
completed: false,
|
||||||
|
icon: '⏳',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (order.status === 'DELIVERED') {
|
||||||
|
timeline.push({
|
||||||
|
status: 'OUT_FOR_DELIVERY',
|
||||||
|
title: 'Out for Delivery',
|
||||||
|
description: 'Your order is out for delivery',
|
||||||
|
timestamp: order.deliveredAt || new Date(),
|
||||||
|
completed: true,
|
||||||
|
icon: '🛵',
|
||||||
|
});
|
||||||
|
} else if (order.status === 'SHIPPED') {
|
||||||
|
timeline.push({
|
||||||
|
status: 'OUT_FOR_DELIVERY',
|
||||||
|
title: 'Out for Delivery',
|
||||||
|
description: 'Will be out for delivery soon',
|
||||||
|
completed: false,
|
||||||
|
icon: '⏳',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (order.status === 'DELIVERED') {
|
||||||
|
timeline.push({
|
||||||
|
status: 'DELIVERED',
|
||||||
|
title: 'Delivered',
|
||||||
|
description: 'Your order has been delivered',
|
||||||
|
timestamp: order.deliveredAt,
|
||||||
|
completed: true,
|
||||||
|
icon: '🎉',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const estimatedDate = deliveryEstimation?.estimatedDelivery?.formatted;
|
||||||
|
timeline.push({
|
||||||
|
status: 'DELIVERED',
|
||||||
|
title: 'Delivery',
|
||||||
|
description: estimatedDate
|
||||||
|
? `Expected by ${estimatedDate}`
|
||||||
|
: 'Estimated delivery date will be updated',
|
||||||
|
completed: false,
|
||||||
|
icon: '📍',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return timeline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current status details
|
||||||
|
*/
|
||||||
|
function getStatusDetails(order) {
|
||||||
|
const statusMap = {
|
||||||
|
PENDING: {
|
||||||
|
label: 'Order Pending',
|
||||||
|
description: 'Your order is awaiting confirmation',
|
||||||
|
color: 'yellow',
|
||||||
|
progress: 10,
|
||||||
|
},
|
||||||
|
CONFIRMED: {
|
||||||
|
label: 'Order Confirmed',
|
||||||
|
description: 'Your order has been confirmed and will be processed soon',
|
||||||
|
color: 'blue',
|
||||||
|
progress: 25,
|
||||||
|
},
|
||||||
|
PROCESSING: {
|
||||||
|
label: 'Processing',
|
||||||
|
description: 'Your order is being prepared for shipment',
|
||||||
|
color: 'blue',
|
||||||
|
progress: 50,
|
||||||
|
},
|
||||||
|
SHIPPED: {
|
||||||
|
label: 'Shipped',
|
||||||
|
description: 'Your order is on the way',
|
||||||
|
color: 'purple',
|
||||||
|
progress: 75,
|
||||||
|
},
|
||||||
|
DELIVERED: {
|
||||||
|
label: 'Delivered',
|
||||||
|
description: 'Your order has been delivered',
|
||||||
|
color: 'green',
|
||||||
|
progress: 100,
|
||||||
|
},
|
||||||
|
CANCELLED: {
|
||||||
|
label: 'Cancelled',
|
||||||
|
description: 'Your order has been cancelled',
|
||||||
|
color: 'red',
|
||||||
|
progress: 0,
|
||||||
|
},
|
||||||
|
RETURN_REQUESTED: {
|
||||||
|
label: 'Return Requested',
|
||||||
|
description: 'Return request is being processed',
|
||||||
|
color: 'orange',
|
||||||
|
progress: 100,
|
||||||
|
},
|
||||||
|
REFUNDED: {
|
||||||
|
label: 'Refunded',
|
||||||
|
description: 'Your order has been refunded',
|
||||||
|
color: 'green',
|
||||||
|
progress: 100,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return statusMap[order.status] || statusMap.PENDING;
|
||||||
|
}
|
||||||
430
src/controllers/payment/paytmController.js
Normal file
430
src/controllers/payment/paytmController.js
Normal file
@@ -0,0 +1,430 @@
|
|||||||
|
// controllers/paytmController.js
|
||||||
|
const { prisma } = require('../../config/database');
|
||||||
|
const {
|
||||||
|
initiateTransaction,
|
||||||
|
checkTransactionStatus,
|
||||||
|
verifyChecksum,
|
||||||
|
processRefund,
|
||||||
|
PaytmConfig,
|
||||||
|
} = require('../../utils/paytm');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @desc Initiate Paytm Payment
|
||||||
|
* @route POST /api/payments/paytm/initiate
|
||||||
|
* @access Private
|
||||||
|
*/
|
||||||
|
exports.initiatePayment = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { orderId, amount } = req.body;
|
||||||
|
const userId = req.user.id;
|
||||||
|
|
||||||
|
// Validate order
|
||||||
|
const order = await prisma.order.findUnique({
|
||||||
|
where: { id: orderId },
|
||||||
|
include: {
|
||||||
|
user: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!order) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Order not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify order belongs to user
|
||||||
|
if (order.userId !== userId) {
|
||||||
|
return res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Unauthorized access to order',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if order is already paid
|
||||||
|
if (order.paymentStatus === 'PAID') {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Order is already paid',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate amount
|
||||||
|
const orderAmount = parseFloat(order.totalAmount);
|
||||||
|
if (parseFloat(amount) !== orderAmount) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Payment amount mismatch',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initiate Paytm transaction
|
||||||
|
const paytmResponse = await initiateTransaction(
|
||||||
|
order.orderNumber,
|
||||||
|
orderAmount,
|
||||||
|
userId,
|
||||||
|
order.user.email,
|
||||||
|
order.user.phone || '9999999999'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!paytmResponse.success) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to initiate payment',
|
||||||
|
error: paytmResponse,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update order with payment details
|
||||||
|
await prisma.order.update({
|
||||||
|
where: { id: orderId },
|
||||||
|
data: {
|
||||||
|
paymentMethod: 'PAYTM',
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Payment initiated successfully',
|
||||||
|
data: {
|
||||||
|
txnToken: paytmResponse.txnToken,
|
||||||
|
orderId: order.orderNumber,
|
||||||
|
amount: orderAmount,
|
||||||
|
mid: PaytmConfig.mid,
|
||||||
|
website: PaytmConfig.website,
|
||||||
|
callbackUrl: PaytmConfig.callbackUrl,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Paytm initiate payment error:', error);
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @desc Paytm Payment Callback
|
||||||
|
* @route POST /api/payments/paytm/callback
|
||||||
|
* @access Public (Called by Paytm)
|
||||||
|
*/
|
||||||
|
exports.paymentCallback = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const paytmChecksum = req.body.CHECKSUMHASH;
|
||||||
|
delete req.body.CHECKSUMHASH;
|
||||||
|
|
||||||
|
// Verify checksum
|
||||||
|
const isValidChecksum = await verifyChecksum(
|
||||||
|
req.body,
|
||||||
|
PaytmConfig.key,
|
||||||
|
paytmChecksum
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isValidChecksum) {
|
||||||
|
console.error('Invalid checksum received from Paytm');
|
||||||
|
return res.redirect(`${process.env.FRONTEND_URL}/payment/failed?reason=invalid_checksum`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
ORDERID,
|
||||||
|
TXNID,
|
||||||
|
TXNAMOUNT,
|
||||||
|
STATUS,
|
||||||
|
RESPCODE,
|
||||||
|
RESPMSG,
|
||||||
|
TXNDATE,
|
||||||
|
BANKTXNID,
|
||||||
|
GATEWAYNAME,
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
console.log('Paytm callback received:', {
|
||||||
|
orderId: ORDERID,
|
||||||
|
txnId: TXNID,
|
||||||
|
status: STATUS,
|
||||||
|
amount: TXNAMOUNT,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Find order by order number
|
||||||
|
const order = await prisma.order.findUnique({
|
||||||
|
where: { orderNumber: ORDERID },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!order) {
|
||||||
|
console.error('Order not found:', ORDERID);
|
||||||
|
return res.redirect(`${process.env.FRONTEND_URL}/payment/failed?reason=order_not_found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update order based on payment status
|
||||||
|
if (STATUS === 'TXN_SUCCESS') {
|
||||||
|
await prisma.order.update({
|
||||||
|
where: { id: order.id },
|
||||||
|
data: {
|
||||||
|
paymentStatus: 'PAID',
|
||||||
|
paymentId: TXNID,
|
||||||
|
paymentMethod: `PAYTM - ${GATEWAYNAME || 'Gateway'}`,
|
||||||
|
status: 'CONFIRMED',
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Redirect to success page
|
||||||
|
return res.redirect(
|
||||||
|
`${process.env.FRONTEND_URL}/payment/success?orderId=${order.id}&txnId=${TXNID}`
|
||||||
|
);
|
||||||
|
} else if (STATUS === 'TXN_FAILURE') {
|
||||||
|
await prisma.order.update({
|
||||||
|
where: { id: order.id },
|
||||||
|
data: {
|
||||||
|
paymentStatus: 'FAILED',
|
||||||
|
paymentId: TXNID,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.redirect(
|
||||||
|
`${process.env.FRONTEND_URL}/payment/failed?orderId=${order.id}&reason=${RESPMSG}`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Pending or other status
|
||||||
|
return res.redirect(
|
||||||
|
`${process.env.FRONTEND_URL}/payment/pending?orderId=${order.id}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Paytm callback error:', error);
|
||||||
|
return res.redirect(
|
||||||
|
`${process.env.FRONTEND_URL}/payment/failed?reason=processing_error`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @desc Check Payment Status
|
||||||
|
* @route GET /api/payments/paytm/status/:orderId
|
||||||
|
* @access Private
|
||||||
|
*/
|
||||||
|
exports.checkPaymentStatus = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { orderId } = req.params;
|
||||||
|
const userId = req.user.id;
|
||||||
|
|
||||||
|
// Get order
|
||||||
|
const order = await prisma.order.findUnique({
|
||||||
|
where: { id: orderId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!order) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Order not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify order belongs to user
|
||||||
|
if (order.userId !== userId) {
|
||||||
|
return res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Unauthorized access',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check status with Paytm
|
||||||
|
const statusResponse = await checkTransactionStatus(order.orderNumber);
|
||||||
|
|
||||||
|
if (statusResponse.body && statusResponse.body.resultInfo) {
|
||||||
|
const { resultStatus } = statusResponse.body.resultInfo;
|
||||||
|
const txnInfo = statusResponse.body;
|
||||||
|
|
||||||
|
// Update order if status changed
|
||||||
|
if (resultStatus === 'TXN_SUCCESS' && order.paymentStatus !== 'PAID') {
|
||||||
|
await prisma.order.update({
|
||||||
|
where: { id: orderId },
|
||||||
|
data: {
|
||||||
|
paymentStatus: 'PAID',
|
||||||
|
paymentId: txnInfo.txnId,
|
||||||
|
status: 'CONFIRMED',
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
orderId: order.id,
|
||||||
|
orderNumber: order.orderNumber,
|
||||||
|
paymentStatus: order.paymentStatus,
|
||||||
|
paytmStatus: resultStatus,
|
||||||
|
txnInfo: txnInfo,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
orderId: order.id,
|
||||||
|
orderNumber: order.orderNumber,
|
||||||
|
paymentStatus: order.paymentStatus,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Check payment status error:', error);
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @desc Process Refund
|
||||||
|
* @route POST /api/payments/paytm/refund
|
||||||
|
* @access Private/Admin
|
||||||
|
*/
|
||||||
|
exports.processRefund = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { orderId, amount, reason } = req.body;
|
||||||
|
|
||||||
|
// Get order
|
||||||
|
const order = await prisma.order.findUnique({
|
||||||
|
where: { id: orderId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!order) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Order not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate refund amount
|
||||||
|
const orderAmount = parseFloat(order.totalAmount);
|
||||||
|
const refundAmount = parseFloat(amount);
|
||||||
|
|
||||||
|
if (refundAmount > orderAmount) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Refund amount cannot exceed order amount',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if order is paid
|
||||||
|
if (order.paymentStatus !== 'PAID') {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Cannot refund unpaid order',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate unique refund ID
|
||||||
|
const refId = `REFUND_${order.orderNumber}_${Date.now()}`;
|
||||||
|
|
||||||
|
// Process refund with Paytm
|
||||||
|
const refundResponse = await processRefund(
|
||||||
|
order.orderNumber,
|
||||||
|
refId,
|
||||||
|
order.paymentId,
|
||||||
|
refundAmount
|
||||||
|
);
|
||||||
|
|
||||||
|
if (refundResponse.body && refundResponse.body.resultInfo) {
|
||||||
|
const { resultStatus, resultMsg } = refundResponse.body.resultInfo;
|
||||||
|
|
||||||
|
if (resultStatus === 'TXN_SUCCESS' || resultStatus === 'PENDING') {
|
||||||
|
// Update order
|
||||||
|
const isFullRefund = refundAmount >= orderAmount;
|
||||||
|
|
||||||
|
await prisma.order.update({
|
||||||
|
where: { id: orderId },
|
||||||
|
data: {
|
||||||
|
paymentStatus: isFullRefund ? 'REFUNDED' : 'PARTIALLY_REFUNDED',
|
||||||
|
status: isFullRefund ? 'REFUNDED' : order.status,
|
||||||
|
returnStatus: 'COMPLETED',
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Refund processed successfully',
|
||||||
|
data: {
|
||||||
|
refId,
|
||||||
|
status: resultStatus,
|
||||||
|
message: resultMsg,
|
||||||
|
amount: refundAmount,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: `Refund failed: ${resultMsg}`,
|
||||||
|
data: refundResponse,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to process refund',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Process refund error:', error);
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @desc Get Payment Details
|
||||||
|
* @route GET /api/payments/paytm/:orderId
|
||||||
|
* @access Private
|
||||||
|
*/
|
||||||
|
exports.getPaymentDetails = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { orderId } = req.params;
|
||||||
|
const userId = req.user.id;
|
||||||
|
|
||||||
|
const order = await prisma.order.findUnique({
|
||||||
|
where: { id: orderId },
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!order) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Order not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify access
|
||||||
|
if (order.userId !== userId && req.user.role !== 'ADMIN') {
|
||||||
|
return res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Unauthorized access',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
orderId: order.id,
|
||||||
|
orderNumber: order.orderNumber,
|
||||||
|
totalAmount: order.totalAmount,
|
||||||
|
paymentStatus: order.paymentStatus,
|
||||||
|
paymentMethod: order.paymentMethod,
|
||||||
|
paymentId: order.paymentId,
|
||||||
|
createdAt: order.createdAt,
|
||||||
|
user: order.user,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get payment details error:', error);
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
811
src/controllers/products/productController.js
Normal file
811
src/controllers/products/productController.js
Normal file
@@ -0,0 +1,811 @@
|
|||||||
|
const Product = require('../../models/mongodb/Product');
|
||||||
|
const { PrismaClient } = require('@prisma/client');
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
// Get all products
|
||||||
|
exports.getAllProducts = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
page = 1,
|
||||||
|
limit = 20,
|
||||||
|
category,
|
||||||
|
brand,
|
||||||
|
minPrice,
|
||||||
|
maxPrice,
|
||||||
|
search,
|
||||||
|
sort = 'createdAt',
|
||||||
|
order = 'desc',
|
||||||
|
} = req.query;
|
||||||
|
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
let query = { status: 'active' };
|
||||||
|
|
||||||
|
if (category) query.category = category;
|
||||||
|
if (brand) query.brand = brand;
|
||||||
|
if (minPrice || maxPrice) {
|
||||||
|
query.basePrice = {};
|
||||||
|
if (minPrice) query.basePrice.$gte = parseFloat(minPrice);
|
||||||
|
if (maxPrice) query.basePrice.$lte = parseFloat(maxPrice);
|
||||||
|
}
|
||||||
|
if (search) query.$text = { $search: search };
|
||||||
|
|
||||||
|
const sortOptions = {};
|
||||||
|
sortOptions[sort] = order === 'desc' ? -1 : 1;
|
||||||
|
|
||||||
|
const [products, total] = await Promise.all([
|
||||||
|
Product.find(query)
|
||||||
|
.sort(sortOptions)
|
||||||
|
.skip(skip)
|
||||||
|
.limit(parseInt(limit))
|
||||||
|
.lean(),
|
||||||
|
Product.countDocuments(query),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// res.json({
|
||||||
|
// success: true,
|
||||||
|
// data: {
|
||||||
|
// products,
|
||||||
|
// pagination: {
|
||||||
|
// page: parseInt(page),
|
||||||
|
// limit: parseInt(limit),
|
||||||
|
// total,
|
||||||
|
// pages: Math.ceil(total / limit),
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
return res.status(200).json({
|
||||||
|
statusCode: 200,
|
||||||
|
status: true,
|
||||||
|
message: 'Products fetched successfully',
|
||||||
|
data: {
|
||||||
|
products,
|
||||||
|
pagination: {
|
||||||
|
page: parseInt(page),
|
||||||
|
limit: parseInt(limit),
|
||||||
|
total,
|
||||||
|
pages: Math.ceil(total / limit),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get single product by slug
|
||||||
|
exports.getProductBySlug = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const product = await Product.findBySlug(req.params.slug);
|
||||||
|
// if (!product)
|
||||||
|
// return res
|
||||||
|
// .status(404)
|
||||||
|
// .json({ success: false, message: 'Product not found' });
|
||||||
|
if (!product) {
|
||||||
|
return res.status(404).json({
|
||||||
|
statusCode: 404,
|
||||||
|
status: false,
|
||||||
|
message: 'Product not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await product.incrementViewCount();
|
||||||
|
|
||||||
|
// res.json({ success: true, data: { product } });
|
||||||
|
return res.status(200).json({
|
||||||
|
statusCode: 200,
|
||||||
|
status: true,
|
||||||
|
message: 'Product fetched successfully',
|
||||||
|
data: { product },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create new product
|
||||||
|
exports.createProduct = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const product = new Product(req.body);
|
||||||
|
await product.save();
|
||||||
|
|
||||||
|
// res.status(201).json({
|
||||||
|
// success: true,
|
||||||
|
// message: 'Product created successfully',
|
||||||
|
// data: { product },
|
||||||
|
// });
|
||||||
|
return res.status(201).json({
|
||||||
|
statusCode: 201,
|
||||||
|
status: true,
|
||||||
|
message: 'Product created successfully',
|
||||||
|
data: { product },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Update product
|
||||||
|
exports.updateProduct = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const product = await Product.findByIdAndUpdate(req.params.id, req.body, {
|
||||||
|
new: true,
|
||||||
|
runValidators: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// if (!product)
|
||||||
|
// return res
|
||||||
|
// .status(404)
|
||||||
|
// .json({ success: false, message: 'Product not found' });
|
||||||
|
|
||||||
|
if (!product) {
|
||||||
|
return res.status(404).json({
|
||||||
|
statusCode: 404,
|
||||||
|
status: false,
|
||||||
|
message: 'Product not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// res.json({
|
||||||
|
// success: true,
|
||||||
|
// message: 'Product updated successfully',
|
||||||
|
// data: { product },
|
||||||
|
// });
|
||||||
|
return res.status(200).json({
|
||||||
|
statusCode: 200,
|
||||||
|
status: true,
|
||||||
|
message: 'Product updated successfully',
|
||||||
|
data: { product },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Delete product
|
||||||
|
exports.deleteProduct = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const product = await Product.findByIdAndDelete(req.params.id);
|
||||||
|
// if (!product)
|
||||||
|
// return res
|
||||||
|
// .status(404)
|
||||||
|
// .json({ success: false, message: 'Product not found' });
|
||||||
|
if (!product) {
|
||||||
|
return res.status(404).json({
|
||||||
|
statusCode: 404,
|
||||||
|
status: false,
|
||||||
|
message: 'Product not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// res.json({ success: true, message: 'Product deleted successfully' });
|
||||||
|
return res.status(200).json({
|
||||||
|
statusCode: 200,
|
||||||
|
status: true,
|
||||||
|
message: 'Product deleted successfully',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Search products
|
||||||
|
exports.searchProducts = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { query } = req.params;
|
||||||
|
const {
|
||||||
|
category,
|
||||||
|
brand,
|
||||||
|
minPrice,
|
||||||
|
maxPrice,
|
||||||
|
limit = 20,
|
||||||
|
skip = 0,
|
||||||
|
} = req.query;
|
||||||
|
|
||||||
|
const products = await Product.searchProducts(query, {
|
||||||
|
category,
|
||||||
|
brand,
|
||||||
|
minPrice: minPrice ? parseFloat(minPrice) : undefined,
|
||||||
|
maxPrice: maxPrice ? parseFloat(maxPrice) : undefined,
|
||||||
|
limit: parseInt(limit),
|
||||||
|
skip: parseInt(skip),
|
||||||
|
});
|
||||||
|
|
||||||
|
// res.json({ success: true, data: { products } });
|
||||||
|
return res.status(200).json({
|
||||||
|
statusCode: 200,
|
||||||
|
status: true,
|
||||||
|
message: 'Products searched successfully',
|
||||||
|
data: { products },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get products by category
|
||||||
|
// exports.getProductsByCategory = async (req, res, next) => {
|
||||||
|
// try {
|
||||||
|
// const { category } = req.params;
|
||||||
|
// const { limit = 20, skip = 0 } = req.query;
|
||||||
|
|
||||||
|
// const products = await Product.findByCategory(
|
||||||
|
// category,
|
||||||
|
// parseInt(limit),
|
||||||
|
// parseInt(skip)
|
||||||
|
// );
|
||||||
|
|
||||||
|
// // res.json({ success: true, data: { products } });
|
||||||
|
// return res.status(200).json({
|
||||||
|
// statusCode: 200,
|
||||||
|
// status: true,
|
||||||
|
// message: 'Products fetched by category',
|
||||||
|
// data: { products },
|
||||||
|
// });
|
||||||
|
// } catch (error) {
|
||||||
|
// next(error);
|
||||||
|
// }
|
||||||
|
// };
|
||||||
|
|
||||||
|
// exports.getProductsByCategory = async (req, res, next) => {
|
||||||
|
// try {
|
||||||
|
// const { category: slug } = req.params;
|
||||||
|
// const { limit = 20, skip = 0 } = req.query;
|
||||||
|
|
||||||
|
// // 1️⃣ Get category from PostgreSQL
|
||||||
|
// const categoryDoc = await prisma.category.findFirst({
|
||||||
|
// where: { slug, isActive: true },
|
||||||
|
// });
|
||||||
|
|
||||||
|
// if (!categoryDoc) {
|
||||||
|
// return res.status(404).json({
|
||||||
|
// statusCode: 404,
|
||||||
|
// status: false,
|
||||||
|
// message: "Category not found",
|
||||||
|
// data: null
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // 2️⃣ Get products from MongoDB using the PostgreSQL category ID
|
||||||
|
// const products = await Product.findByCategory(
|
||||||
|
// categoryDoc.id, // <-- MongoDB stores this as the `category` field
|
||||||
|
// parseInt(limit),
|
||||||
|
// parseInt(skip)
|
||||||
|
// );
|
||||||
|
|
||||||
|
// return res.status(200).json({
|
||||||
|
// statusCode: 200,
|
||||||
|
// status: true,
|
||||||
|
// message: "Products fetched by category",
|
||||||
|
// data: { products },
|
||||||
|
// });
|
||||||
|
// } catch (error) {
|
||||||
|
// next(error);
|
||||||
|
// }
|
||||||
|
// };
|
||||||
|
|
||||||
|
// exports.getProductsByCategory = async (req, res, next) => {
|
||||||
|
// try {
|
||||||
|
// const { categorySlug } = req.params;
|
||||||
|
// const limit = parseInt(req.query.limit) || 20;
|
||||||
|
// const skip = parseInt(req.query.skip) || 0;
|
||||||
|
|
||||||
|
// // 1️⃣ Find category from PostgreSQL by slug
|
||||||
|
// const categoryDoc = await prisma.category.findFirst({
|
||||||
|
// where: { slug: categorySlug, isActive: true },
|
||||||
|
// });
|
||||||
|
|
||||||
|
// if (!categoryDoc) {
|
||||||
|
// return res.status(404).json({
|
||||||
|
// statusCode: 404,
|
||||||
|
// status: false,
|
||||||
|
// message: 'Category not found',
|
||||||
|
// data: null,
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // 2️⃣ Find products from MongoDB using the PostgreSQL category ID
|
||||||
|
// const query = { category: categoryDoc.id }; // MongoDB field must store PostgreSQL category id
|
||||||
|
// const [products, totalCount] = await Promise.all([
|
||||||
|
// Product.find(query)
|
||||||
|
// .skip(skip)
|
||||||
|
// .limit(limit)
|
||||||
|
// .sort({ createdAt: -1 }) // optional: newest first
|
||||||
|
// .lean(), // optional: return plain JS objects
|
||||||
|
// Product.countDocuments(query),
|
||||||
|
// ]);
|
||||||
|
|
||||||
|
// return res.status(200).json({
|
||||||
|
// statusCode: 200,
|
||||||
|
// status: true,
|
||||||
|
// message: 'Products fetched by category',
|
||||||
|
// data: { products, totalCount },
|
||||||
|
// });
|
||||||
|
// } catch (error) {
|
||||||
|
// next(error);
|
||||||
|
// }
|
||||||
|
// };
|
||||||
|
|
||||||
|
|
||||||
|
exports.getProductsByCategory = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { categorySlug } = req.params;
|
||||||
|
const limit = parseInt(req.query.limit) || 20;
|
||||||
|
const skip = parseInt(req.query.skip) || 0;
|
||||||
|
|
||||||
|
// 1️⃣ Find category from PostgreSQL
|
||||||
|
const categoryDoc = await prisma.category.findFirst({
|
||||||
|
where: { slug: categorySlug, isActive: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!categoryDoc) {
|
||||||
|
return res.status(404).json({
|
||||||
|
statusCode: 404,
|
||||||
|
status: false,
|
||||||
|
message: 'Category not found',
|
||||||
|
data: { products: [], totalCount: 0 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2️⃣ Find all child categories recursively (if any)
|
||||||
|
const allCategoryIds = [categoryDoc.id];
|
||||||
|
|
||||||
|
const getChildCategoryIds = async (parentId) => {
|
||||||
|
const children = await prisma.category.findMany({
|
||||||
|
where: { parentId, isActive: true },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
for (const child of children) {
|
||||||
|
allCategoryIds.push(child.id);
|
||||||
|
await getChildCategoryIds(child.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await getChildCategoryIds(categoryDoc.id);
|
||||||
|
|
||||||
|
// 3️⃣ Find products in MongoDB with category in allCategoryIds
|
||||||
|
const query = { category: { $in: allCategoryIds } };
|
||||||
|
const [products, totalCount] = await Promise.all([
|
||||||
|
Product.find(query)
|
||||||
|
.skip(skip)
|
||||||
|
.limit(limit)
|
||||||
|
.sort({ createdAt: -1 })
|
||||||
|
.lean(),
|
||||||
|
Product.countDocuments(query),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
statusCode: 200,
|
||||||
|
status: true,
|
||||||
|
message: 'Products fetched by category',
|
||||||
|
data: { products, totalCount },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// Get featured products
|
||||||
|
exports.getFeaturedProducts = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { limit = 10 } = req.query;
|
||||||
|
|
||||||
|
const products = await Product.find({ status: 'active', isFeatured: true })
|
||||||
|
.sort({ createdAt: -1 })
|
||||||
|
.limit(parseInt(limit))
|
||||||
|
.lean();
|
||||||
|
|
||||||
|
// res.json({ success: true, data: { products } });
|
||||||
|
return res.status(200).json({
|
||||||
|
statusCode: 200,
|
||||||
|
status: true,
|
||||||
|
message: 'Featured products fetched successfully',
|
||||||
|
data: { products },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get all unique categories
|
||||||
|
// Get all categories that have products
|
||||||
|
exports.getAllCategories = async (req, res) => {
|
||||||
|
try {
|
||||||
|
// Get unique category IDs from MongoDB products
|
||||||
|
const categoryIds = await Product.distinct('category', {
|
||||||
|
status: 'active',
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Category IDs from products:', categoryIds);
|
||||||
|
|
||||||
|
if (!categoryIds || categoryIds.length === 0) {
|
||||||
|
return res.status(404).json({
|
||||||
|
statusCode: 404,
|
||||||
|
status: false,
|
||||||
|
message: 'No categories found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch category details from PostgreSQL
|
||||||
|
const categories = await prisma.category.findMany({
|
||||||
|
where: {
|
||||||
|
id: { in: categoryIds },
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
slug: true,
|
||||||
|
image: true,
|
||||||
|
description: true,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
name: 'asc',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!categories || categories.length === 0) {
|
||||||
|
return res.status(404).json({
|
||||||
|
statusCode: 404,
|
||||||
|
status: false,
|
||||||
|
message: 'No matching categories found in database',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get product count for each category
|
||||||
|
const productCounts = await Product.aggregate([
|
||||||
|
{
|
||||||
|
$match: {
|
||||||
|
category: { $in: categoryIds },
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$group: {
|
||||||
|
_id: '$category',
|
||||||
|
count: { $sum: 1 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Add product count to each category
|
||||||
|
const categoriesWithCount = categories.map(cat => ({
|
||||||
|
...cat,
|
||||||
|
productCount: productCounts.find(pc => pc._id === cat.id)?.count || 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
console.log('Categories with counts:', categoriesWithCount);
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
statusCode: 200,
|
||||||
|
status: true,
|
||||||
|
data: categoriesWithCount,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error:', error);
|
||||||
|
return res.status(500).json({
|
||||||
|
statusCode: 500,
|
||||||
|
status: false,
|
||||||
|
message: 'Server error',
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.debugMissingCategories = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const categoryIds = await Product.distinct('category');
|
||||||
|
|
||||||
|
const existingCategories = await prisma.category.findMany({
|
||||||
|
where: { id: { in: categoryIds } },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const existingIds = existingCategories.map(c => c.id);
|
||||||
|
const missingIds = categoryIds.filter(id => !existingIds.includes(id));
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
totalProductCategories: categoryIds.length,
|
||||||
|
existingInDB: existingCategories,
|
||||||
|
missingFromDB: missingIds,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// exports.getUserCategoryHierarchy = async (req, res, next) => {
|
||||||
|
// try {
|
||||||
|
// const categories = await prisma.category.findMany({
|
||||||
|
// where: {
|
||||||
|
// isActive: true,
|
||||||
|
// // isVisible: true,
|
||||||
|
// },
|
||||||
|
// select: {
|
||||||
|
// id: true,
|
||||||
|
// name: true,
|
||||||
|
// slug: true,
|
||||||
|
// parentId: true,
|
||||||
|
// },
|
||||||
|
// orderBy: { name: 'asc' },
|
||||||
|
// });
|
||||||
|
|
||||||
|
// const lookup = {};
|
||||||
|
// categories.forEach(cat => {
|
||||||
|
// lookup[cat.id] = { ...cat, children: [] };
|
||||||
|
// });
|
||||||
|
|
||||||
|
// const hierarchy = [];
|
||||||
|
|
||||||
|
// categories.forEach(cat => {
|
||||||
|
// if (cat.parentId && lookup[cat.parentId]) {
|
||||||
|
// lookup[cat.parentId].children.push(lookup[cat.id]);
|
||||||
|
// } else {
|
||||||
|
// hierarchy.push(lookup[cat.id]);
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
|
||||||
|
// res.status(200).json({
|
||||||
|
// statusCode: 200,
|
||||||
|
// status: true,
|
||||||
|
// message: 'Category tree fetched successfully',
|
||||||
|
// data: hierarchy,
|
||||||
|
// });
|
||||||
|
// } catch (error) {
|
||||||
|
// next(error);
|
||||||
|
// }
|
||||||
|
// };
|
||||||
|
|
||||||
|
exports.getUserCategoryHierarchy = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const categories = await prisma.category.findMany({
|
||||||
|
where: {
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
slug: true,
|
||||||
|
parentId: true,
|
||||||
|
sequence: true,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
sequence: 'asc', // ✅ IMPORTANT
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const lookup = {};
|
||||||
|
categories.forEach(cat => {
|
||||||
|
lookup[cat.id] = { ...cat, children: [] };
|
||||||
|
});
|
||||||
|
|
||||||
|
const hierarchy = [];
|
||||||
|
|
||||||
|
categories.forEach(cat => {
|
||||||
|
if (cat.parentId && lookup[cat.parentId]) {
|
||||||
|
lookup[cat.parentId].children.push(lookup[cat.id]);
|
||||||
|
} else {
|
||||||
|
hierarchy.push(lookup[cat.id]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ✅ Recursive sort by sequence
|
||||||
|
const sortTree = nodes => {
|
||||||
|
nodes.sort((a, b) => a.sequence - b.sequence);
|
||||||
|
nodes.forEach(node => {
|
||||||
|
if (node.children.length) {
|
||||||
|
sortTree(node.children);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
sortTree(hierarchy);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
statusCode: 200,
|
||||||
|
status: true,
|
||||||
|
message: 'Category tree fetched successfully',
|
||||||
|
data: hierarchy,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get all available colors for a category
|
||||||
|
// const Product = require('../../models/mongodb/Product');
|
||||||
|
// controllers/products/productController.js
|
||||||
|
exports.getCategoryColors = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { categorySlug } = req.params;
|
||||||
|
|
||||||
|
// 1️⃣ Find category ID from Prisma (PostgreSQL)
|
||||||
|
const category = await prisma.category.findFirst({
|
||||||
|
where: { slug: categorySlug },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!category) {
|
||||||
|
return res.json({
|
||||||
|
status: false,
|
||||||
|
message: 'Category not found',
|
||||||
|
data: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2️⃣ Fetch products from MongoDB using category ID
|
||||||
|
const products = await Product.find(
|
||||||
|
{ category: category.id, status: 'active' },
|
||||||
|
{ variants: 1, images: 1 }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!products.length) {
|
||||||
|
return res.json({
|
||||||
|
status: true,
|
||||||
|
message: 'No products found in this category',
|
||||||
|
data: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3️⃣ Extract unique colors
|
||||||
|
const colorMap = new Map();
|
||||||
|
|
||||||
|
products.forEach((product) => {
|
||||||
|
if (product.variants?.length) {
|
||||||
|
product.variants.forEach((variant) => {
|
||||||
|
if (!variant.color) return; // skip if no color
|
||||||
|
|
||||||
|
const key = variant.color.toLowerCase();
|
||||||
|
|
||||||
|
if (!colorMap.has(key)) {
|
||||||
|
colorMap.set(key, {
|
||||||
|
name: variant.color,
|
||||||
|
slug: key,
|
||||||
|
image: variant.images?.[0] || product.images?.primary || null,
|
||||||
|
bg: getColorBg(key), // Optional: your helper to generate color background
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4️⃣ Prepare response
|
||||||
|
const colors = Array.from(colorMap.values());
|
||||||
|
|
||||||
|
const message =
|
||||||
|
colors.length === 0
|
||||||
|
? 'No color variants available for this category'
|
||||||
|
: colors.length === 1
|
||||||
|
? `Only 1 color available: ${colors[0].name}`
|
||||||
|
: 'Category colors fetched successfully';
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
status: true,
|
||||||
|
message,
|
||||||
|
data: colors,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Category Colors Error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
status: false,
|
||||||
|
message: 'Failed to fetch category colors',
|
||||||
|
data: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Helper function to assign background color (optional)
|
||||||
|
function getColorBg(colorKey) {
|
||||||
|
const defaultBg = '#F5F5F5';
|
||||||
|
const colorMap = {
|
||||||
|
white: '#FFFFFF',
|
||||||
|
black: '#000000',
|
||||||
|
red: '#FF0000',
|
||||||
|
blue: '#007BFF',
|
||||||
|
green: '#28A745',
|
||||||
|
yellow: '#FFC107',
|
||||||
|
// Add more colors if needed
|
||||||
|
};
|
||||||
|
|
||||||
|
return colorMap[colorKey] || defaultBg;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/* helpers */
|
||||||
|
const capitalize = str => str.charAt(0).toUpperCase() + str.slice(1);
|
||||||
|
|
||||||
|
function getColorBg(color) {
|
||||||
|
const map = {
|
||||||
|
white: '#F5F5F5',
|
||||||
|
black: '#E5E5E5',
|
||||||
|
red: '#FDE6E6',
|
||||||
|
blue: '#E6F0FD',
|
||||||
|
green: '#E6FDEB',
|
||||||
|
pink: '#FDE6F1',
|
||||||
|
maroon: '#FDE6EB',
|
||||||
|
beige: '#FDEDE6',
|
||||||
|
grey: '#EFEFEF',
|
||||||
|
gray: '#EFEFEF',
|
||||||
|
brown: '#EEE6D8',
|
||||||
|
yellow: '#FFF7CC',
|
||||||
|
orange: '#FFE5CC',
|
||||||
|
purple: '#F1E6FD',
|
||||||
|
};
|
||||||
|
return map[color] || '#F3F4F6';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @desc Get New Arrivals
|
||||||
|
* @route GET /api/products/new-arrivals
|
||||||
|
* @access Public
|
||||||
|
*/
|
||||||
|
exports.getNewArrivals = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const limit = parseInt(req.query.limit) || 10;
|
||||||
|
|
||||||
|
// Fetch latest active products
|
||||||
|
const products = await Product.find({ status: 'active' })
|
||||||
|
.sort({ createdAt: -1 }) // newest first
|
||||||
|
.limit(limit);
|
||||||
|
|
||||||
|
res.status(200).json({ success: true, data: products });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching new arrivals:', error);
|
||||||
|
res.status(500).json({ success: false, message: 'Server Error' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// controllers/products/productController.js
|
||||||
|
|
||||||
|
// @desc Get Most Loved / Discounted Products
|
||||||
|
// @route GET /api/products/most-loved
|
||||||
|
// @access Public
|
||||||
|
// Get Most Loved Products (based on purchaseCount or discounted items)
|
||||||
|
// @desc Get Most Loved / Discounted Products
|
||||||
|
// @route GET /api/products/most-loved
|
||||||
|
// @access Public
|
||||||
|
exports.getMostLovedProducts = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const limit = parseInt(req.query.limit) || 8;
|
||||||
|
|
||||||
|
// Fetch products with discount or highest purchase count
|
||||||
|
let products = await Product.find({
|
||||||
|
status: 'active',
|
||||||
|
$or: [
|
||||||
|
{ compareAtPrice: { $gt: 0 } }, // discounted
|
||||||
|
{ purchaseCount: { $gt: 0 } }, // most loved
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.sort({ purchaseCount: -1, createdAt: -1 })
|
||||||
|
.limit(limit);
|
||||||
|
|
||||||
|
// If no products match, return latest active products as fallback
|
||||||
|
if (!products.length) {
|
||||||
|
products = await Product.find({ status: 'active' })
|
||||||
|
.sort({ createdAt: -1 })
|
||||||
|
.limit(limit);
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
status: true,
|
||||||
|
message: 'Fallback products fetched successfully',
|
||||||
|
data: { products },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the main products
|
||||||
|
return res.status(200).json({
|
||||||
|
status: true,
|
||||||
|
message: 'Products fetched successfully',
|
||||||
|
data: { products },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching most loved products:', error);
|
||||||
|
return res.status(500).json({ status: false, message: 'Server Error' });
|
||||||
|
}
|
||||||
|
};
|
||||||
345
src/controllers/products/recommendation.js
Normal file
345
src/controllers/products/recommendation.js
Normal file
@@ -0,0 +1,345 @@
|
|||||||
|
// controllers/products/recommendationController.js
|
||||||
|
|
||||||
|
const Product = require('../../models/mongodb/Product');
|
||||||
|
const { prisma } = require('../../config/database');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @desc Get recommendations for a product
|
||||||
|
* @route GET /api/products/:slug/recommendations
|
||||||
|
* @access Public
|
||||||
|
*/
|
||||||
|
const getProductRecommendations = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { slug } = req.params;
|
||||||
|
const { limit = 12 } = req.query;
|
||||||
|
|
||||||
|
// Get the current product
|
||||||
|
const currentProduct = await Product.findOne({ slug, status: 'active' });
|
||||||
|
|
||||||
|
if (!currentProduct) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Product not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build recommendation query
|
||||||
|
const recommendations = await getRecommendedProducts(
|
||||||
|
currentProduct,
|
||||||
|
parseInt(limit)
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
count: recommendations.length,
|
||||||
|
data: recommendations,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get recommendations error:', error);
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @desc Get "Customers also bought" products
|
||||||
|
* @route GET /api/products/:slug/also-bought
|
||||||
|
* @access Public
|
||||||
|
*/
|
||||||
|
const getAlsoBoughtProducts = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { slug } = req.params;
|
||||||
|
const { limit = 8 } = req.query;
|
||||||
|
|
||||||
|
const currentProduct = await Product.findOne({ slug, status: 'active' });
|
||||||
|
|
||||||
|
if (!currentProduct) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Product not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get products frequently bought together
|
||||||
|
const alsoBought = await getFrequentlyBoughtTogether(
|
||||||
|
currentProduct._id.toString(),
|
||||||
|
parseInt(limit)
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
count: alsoBought.length,
|
||||||
|
data: alsoBought,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get also bought error:', error);
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @desc Get similar products
|
||||||
|
* @route GET /api/products/:slug/similar
|
||||||
|
* @access Public
|
||||||
|
*/
|
||||||
|
const getSimilarProducts = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { slug } = req.params;
|
||||||
|
const { limit = 10 } = req.query;
|
||||||
|
|
||||||
|
const currentProduct = await Product.findOne({ slug, status: 'active' });
|
||||||
|
|
||||||
|
if (!currentProduct) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Product not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get similar products based on attributes
|
||||||
|
const similar = await getSimilarProductsByAttributes(
|
||||||
|
currentProduct,
|
||||||
|
parseInt(limit)
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
count: similar.length,
|
||||||
|
data: similar,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get similar products error:', error);
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @desc Get personalized recommendations for user
|
||||||
|
* @route GET /api/products/recommendations/personalized
|
||||||
|
* @access Private
|
||||||
|
*/
|
||||||
|
const getPersonalizedRecommendations = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const userId = req.user?.id;
|
||||||
|
const { limit = 20 } = req.query;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
// Return popular products for non-authenticated users
|
||||||
|
const popularProducts = await Product.find({ status: 'active' })
|
||||||
|
.sort({ purchaseCount: -1, viewCount: -1 })
|
||||||
|
.limit(parseInt(limit));
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
count: popularProducts.length,
|
||||||
|
data: popularProducts,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user's purchase history
|
||||||
|
const userOrders = await prisma.order.findMany({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
status: 'DELIVERED',
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
items: true,
|
||||||
|
},
|
||||||
|
take: 10,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get user's wishlist
|
||||||
|
const wishlist = await prisma.wishlistItem.findMany({
|
||||||
|
where: { userId },
|
||||||
|
take: 20,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Extract product IDs
|
||||||
|
const purchasedProductIds = userOrders.flatMap((order) =>
|
||||||
|
order.items.map((item) => item.productId)
|
||||||
|
);
|
||||||
|
const wishlistProductIds = wishlist.map((item) => item.productId);
|
||||||
|
|
||||||
|
// Get categories and tags from purchased products
|
||||||
|
const purchasedProducts = await Product.find({
|
||||||
|
_id: { $in: purchasedProductIds },
|
||||||
|
});
|
||||||
|
|
||||||
|
const categories = [...new Set(purchasedProducts.map((p) => p.category))];
|
||||||
|
const tags = [
|
||||||
|
...new Set(purchasedProducts.flatMap((p) => p.tags || [])),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Build personalized recommendations
|
||||||
|
const recommendations = await Product.find({
|
||||||
|
status: 'active',
|
||||||
|
_id: {
|
||||||
|
$nin: [...purchasedProductIds, ...wishlistProductIds],
|
||||||
|
},
|
||||||
|
$or: [
|
||||||
|
{ category: { $in: categories } },
|
||||||
|
{ tags: { $in: tags } },
|
||||||
|
{ isFeatured: true },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.sort({ purchaseCount: -1, viewCount: -1 })
|
||||||
|
.limit(parseInt(limit));
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
count: recommendations.length,
|
||||||
|
data: recommendations,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get personalized recommendations error:', error);
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper Functions
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Get recommended products based on multiple factors
|
||||||
|
async function getRecommendedProducts(currentProduct, limit) {
|
||||||
|
const priceRange = {
|
||||||
|
min: currentProduct.basePrice * 0.7,
|
||||||
|
max: currentProduct.basePrice * 1.3,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Score-based recommendation
|
||||||
|
const recommendations = await Product.aggregate([
|
||||||
|
{
|
||||||
|
$match: {
|
||||||
|
_id: { $ne: currentProduct._id },
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$addFields: {
|
||||||
|
score: {
|
||||||
|
$add: [
|
||||||
|
{ $cond: [{ $eq: ['$category', currentProduct.category] }, 50, 0] },
|
||||||
|
{
|
||||||
|
$cond: [
|
||||||
|
{
|
||||||
|
$and: [
|
||||||
|
{ $gte: ['$basePrice', priceRange.min] },
|
||||||
|
{ $lte: ['$basePrice', priceRange.max] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
30,
|
||||||
|
0,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$multiply: [
|
||||||
|
{
|
||||||
|
$size: {
|
||||||
|
$ifNull: [
|
||||||
|
{
|
||||||
|
$setIntersection: [
|
||||||
|
{ $ifNull: ['$tags', []] },
|
||||||
|
currentProduct.tags || [],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
5,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ $cond: ['$isFeatured', 20, 0] },
|
||||||
|
{ $divide: [{ $ifNull: ['$viewCount', 0] }, 100] },
|
||||||
|
{ $divide: [{ $multiply: [{ $ifNull: ['$purchaseCount', 0] }, 10] }, 10] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ $sort: { score: -1 } },
|
||||||
|
{ $limit: limit },
|
||||||
|
]);
|
||||||
|
|
||||||
|
return recommendations;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get frequently bought together products
|
||||||
|
async function getFrequentlyBoughtTogether(productId, limit) {
|
||||||
|
try {
|
||||||
|
const ordersWithProduct = await prisma.orderItem.findMany({
|
||||||
|
where: { productId },
|
||||||
|
select: { orderId: true },
|
||||||
|
distinct: ['orderId'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (ordersWithProduct.length === 0) {
|
||||||
|
const product = await Product.findById(productId);
|
||||||
|
return await Product.find({
|
||||||
|
_id: { $ne: productId },
|
||||||
|
category: product.category,
|
||||||
|
status: 'active',
|
||||||
|
})
|
||||||
|
.sort({ purchaseCount: -1 })
|
||||||
|
.limit(limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderIds = ordersWithProduct.map((item) => item.orderId);
|
||||||
|
|
||||||
|
const otherProducts = await prisma.orderItem.findMany({
|
||||||
|
where: {
|
||||||
|
orderId: { in: orderIds },
|
||||||
|
productId: { not: productId },
|
||||||
|
},
|
||||||
|
select: { productId: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const productFrequency = {};
|
||||||
|
otherProducts.forEach((item) => {
|
||||||
|
productFrequency[item.productId] =
|
||||||
|
(productFrequency[item.productId] || 0) + 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
const sortedProductIds = Object.entries(productFrequency)
|
||||||
|
.sort(([, a], [, b]) => b - a)
|
||||||
|
.slice(0, limit)
|
||||||
|
.map(([id]) => id);
|
||||||
|
|
||||||
|
const products = await Product.find({
|
||||||
|
_id: { $in: sortedProductIds },
|
||||||
|
status: 'active',
|
||||||
|
});
|
||||||
|
|
||||||
|
return products;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get frequently bought together error:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get similar products by attributes
|
||||||
|
async function getSimilarProductsByAttributes(currentProduct, limit) {
|
||||||
|
const similar = await Product.find({
|
||||||
|
_id: { $ne: currentProduct._id },
|
||||||
|
status: 'active',
|
||||||
|
$or: [
|
||||||
|
{ category: currentProduct.category },
|
||||||
|
{ tags: { $in: currentProduct.tags || [] } },
|
||||||
|
{ brand: currentProduct.brand },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.sort({
|
||||||
|
purchaseCount: -1,
|
||||||
|
viewCount: -1,
|
||||||
|
})
|
||||||
|
.limit(limit);
|
||||||
|
|
||||||
|
return similar;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getProductRecommendations,
|
||||||
|
getAlsoBoughtProducts,
|
||||||
|
getSimilarProducts,
|
||||||
|
getPersonalizedRecommendations,
|
||||||
|
};
|
||||||
128
src/controllers/users/addressController.js
Normal file
128
src/controllers/users/addressController.js
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
const { prisma } = require('../../config/database');
|
||||||
|
|
||||||
|
// Get addresses
|
||||||
|
exports.getAddresses = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const addresses = await prisma.address.findMany({
|
||||||
|
where: { userId: req.user.id },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
// res.json({ success: true, data: { addresses } });
|
||||||
|
return res.json({
|
||||||
|
statusCode: 200,
|
||||||
|
status: true,
|
||||||
|
message: 'Addresses fetched successfully',
|
||||||
|
data: { addresses },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add address
|
||||||
|
exports.addAddress = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const data = { ...req.body, userId: req.user.id };
|
||||||
|
|
||||||
|
if (data.isDefault) {
|
||||||
|
await prisma.address.updateMany({
|
||||||
|
where: { userId: req.user.id },
|
||||||
|
data: { isDefault: false },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const address = await prisma.address.create({ data });
|
||||||
|
// res.status(201).json({ success: true, message: 'Address added successfully', data: { address } });
|
||||||
|
return res.status(201).json({
|
||||||
|
statusCode: 201,
|
||||||
|
status: true,
|
||||||
|
message: 'Address added successfully',
|
||||||
|
data: { address },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update address
|
||||||
|
exports.updateAddress = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const existingAddress = await prisma.address.findFirst({
|
||||||
|
where: { id, userId: req.user.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
// if (!existingAddress) {
|
||||||
|
// return res
|
||||||
|
// .status(404)
|
||||||
|
// .json({ success: false, message: 'Address not found' });
|
||||||
|
// }
|
||||||
|
|
||||||
|
if (!existingAddress) {
|
||||||
|
return res.status(404).json({
|
||||||
|
statusCode: 404,
|
||||||
|
status: false,
|
||||||
|
message: 'Address not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.body.isDefault) {
|
||||||
|
await prisma.address.updateMany({
|
||||||
|
where: { userId: req.user.id },
|
||||||
|
data: { isDefault: false },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedAddress = await prisma.address.update({
|
||||||
|
where: { id },
|
||||||
|
data: req.body,
|
||||||
|
});
|
||||||
|
// res.json({
|
||||||
|
// success: true,
|
||||||
|
// message: 'Address updated successfully',
|
||||||
|
// data: { address: updatedAddress },
|
||||||
|
// });
|
||||||
|
return res.status(200).json({
|
||||||
|
statusCode: 200,
|
||||||
|
status: true,
|
||||||
|
message: 'Address updated successfully',
|
||||||
|
data: { address: updatedAddress },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Delete address
|
||||||
|
exports.deleteAddress = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const existing = await prisma.address.findFirst({
|
||||||
|
where: { id, userId: req.user.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
// if (!existing) {
|
||||||
|
// return res
|
||||||
|
// .status(404)
|
||||||
|
// .json({ success: false, message: 'Address not found' });
|
||||||
|
// }
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return res.status(404).json({
|
||||||
|
statusCode: 404,
|
||||||
|
status: false,
|
||||||
|
message: 'Address not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.address.delete({ where: { id } });
|
||||||
|
// res.json({ success: true, message: 'Address deleted successfully' });
|
||||||
|
return res.status(200).json({
|
||||||
|
statusCode: 200,
|
||||||
|
status: true,
|
||||||
|
message: 'Address deleted successfully',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
70
src/controllers/users/adminUserController.js
Normal file
70
src/controllers/users/adminUserController.js
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
const { prisma } = require('../../config/database');
|
||||||
|
|
||||||
|
exports.getAllUsers = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { page = 1, limit = 20, role, search } = req.query;
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
const where = {};
|
||||||
|
|
||||||
|
if (role) where.role = role;
|
||||||
|
if (search) {
|
||||||
|
where.OR = [
|
||||||
|
{ email: { contains: search, mode: 'insensitive' } },
|
||||||
|
{ firstName: { contains: search, mode: 'insensitive' } },
|
||||||
|
{ lastName: { contains: search, mode: 'insensitive' } },
|
||||||
|
{ username: { contains: search, mode: 'insensitive' } },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const [users, total] = await Promise.all([
|
||||||
|
prisma.user.findMany({
|
||||||
|
where,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
username: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
role: true,
|
||||||
|
isVerified: true,
|
||||||
|
isActive: true,
|
||||||
|
createdAt: true,
|
||||||
|
lastLoginAt: true,
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
skip: parseInt(skip),
|
||||||
|
take: parseInt(limit),
|
||||||
|
}),
|
||||||
|
prisma.user.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// res.json({
|
||||||
|
// success: true,
|
||||||
|
// data: {
|
||||||
|
// users,
|
||||||
|
// pagination: {
|
||||||
|
// page: parseInt(page),
|
||||||
|
// limit: parseInt(limit),
|
||||||
|
// total,
|
||||||
|
// pages: Math.ceil(total / limit),
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
return res.status(200).json({
|
||||||
|
statusCode: 200,
|
||||||
|
status: true,
|
||||||
|
message: "Users fetched successfully",
|
||||||
|
data: {
|
||||||
|
users,
|
||||||
|
pagination: {
|
||||||
|
page: parseInt(page),
|
||||||
|
limit: parseInt(limit),
|
||||||
|
total,
|
||||||
|
pages: Math.ceil(total / limit),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
150
src/controllers/users/cartController.js
Normal file
150
src/controllers/users/cartController.js
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
const mongoose = require("mongoose");
|
||||||
|
// const Product = require("../models/Product");
|
||||||
|
const Product = require('../../models/mongodb/Product');
|
||||||
|
const { PrismaClient } = require("@prisma/client");
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
|
||||||
|
// Add to Cart
|
||||||
|
exports.addToCart = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { productId, quantity = 1 } = req.body;
|
||||||
|
|
||||||
|
if (!productId) {
|
||||||
|
return res.status(400).json({
|
||||||
|
status: false,
|
||||||
|
message: "Product ID is required",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate ObjectId
|
||||||
|
if (!mongoose.Types.ObjectId.isValid(productId)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
status: false,
|
||||||
|
message: "Invalid product ID",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if product exists in MongoDB
|
||||||
|
const productExists = await Product.findById(productId);
|
||||||
|
if (!productExists) {
|
||||||
|
return res.status(404).json({
|
||||||
|
status: false,
|
||||||
|
message: "Product not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if item already exists in cart
|
||||||
|
const existing = await prisma.cartItem.findUnique({
|
||||||
|
where: { userId_productId: { userId: req.user.id, productId } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
// Update quantity
|
||||||
|
const updated = await prisma.cartItem.update({
|
||||||
|
where: { userId_productId: { userId: req.user.id, productId } },
|
||||||
|
data: { quantity: existing.quantity + quantity },
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
status: true,
|
||||||
|
message: "Cart quantity updated",
|
||||||
|
data: updated,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new cart item
|
||||||
|
const item = await prisma.cartItem.create({
|
||||||
|
data: {
|
||||||
|
userId: req.user.id,
|
||||||
|
productId,
|
||||||
|
quantity,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(201).json({
|
||||||
|
status: true,
|
||||||
|
message: "Item added to cart",
|
||||||
|
data: item,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
//Get User Cart
|
||||||
|
exports.getCart = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const cart = await prisma.cartItem.findMany({
|
||||||
|
where: { userId: req.user.id },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch product details from MongoDB
|
||||||
|
const productIds = cart.map((item) => item.productId);
|
||||||
|
const products = await Product.find({ _id: { $in: productIds } });
|
||||||
|
|
||||||
|
// Merge product details into cart
|
||||||
|
const cartWithProducts = cart.map((item) => ({
|
||||||
|
...item,
|
||||||
|
product: products.find((p) => p._id.toString() === item.productId),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
status: true,
|
||||||
|
message: "Cart fetched successfully",
|
||||||
|
data: cartWithProducts,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
//Update Quantity
|
||||||
|
exports.updateQuantity = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { productId } = req.params;
|
||||||
|
const { quantity } = req.body;
|
||||||
|
|
||||||
|
if (!quantity || quantity < 1) {
|
||||||
|
return res.status(400).json({
|
||||||
|
status: false,
|
||||||
|
message: "Quantity must be at least 1",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.cartItem.update({
|
||||||
|
where: { userId_productId: { userId: req.user.id, productId } },
|
||||||
|
data: { quantity },
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
status: true,
|
||||||
|
message: "Cart quantity updated",
|
||||||
|
data: updated,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
//Remove From Cart
|
||||||
|
exports.removeFromCart = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { productId } = req.params;
|
||||||
|
|
||||||
|
await prisma.cartItem.delete({
|
||||||
|
where: { userId_productId: { userId: req.user.id, productId } },
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
status: true,
|
||||||
|
message: "Item removed from cart",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
51
src/controllers/users/orderController.js
Normal file
51
src/controllers/users/orderController.js
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
const { prisma } = require('../../config/database');
|
||||||
|
|
||||||
|
exports.getOrders = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { page = 1, limit = 10, status } = req.query;
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
const where = { userId: req.user.id };
|
||||||
|
if (status) where.status = status;
|
||||||
|
|
||||||
|
const [orders, total] = await Promise.all([
|
||||||
|
prisma.order.findMany({
|
||||||
|
where,
|
||||||
|
include: { items: true, address: true },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
skip: parseInt(skip),
|
||||||
|
take: parseInt(limit),
|
||||||
|
}),
|
||||||
|
prisma.order.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// res.json({
|
||||||
|
// success: true,
|
||||||
|
// data: {
|
||||||
|
// orders,
|
||||||
|
// pagination: {
|
||||||
|
// page: parseInt(page),
|
||||||
|
// limit: parseInt(limit),
|
||||||
|
// total,
|
||||||
|
// pages: Math.ceil(total / limit),
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
statusCode: 200,
|
||||||
|
status: true,
|
||||||
|
message: "Orders fetched successfully",
|
||||||
|
data: {
|
||||||
|
orders,
|
||||||
|
pagination: {
|
||||||
|
page: parseInt(page),
|
||||||
|
limit: parseInt(limit),
|
||||||
|
total,
|
||||||
|
pages: Math.ceil(total / limit),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
113
src/controllers/users/profileController.js
Normal file
113
src/controllers/users/profileController.js
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
const { prisma } = require('../../config/database');
|
||||||
|
const uploadToS3 = require('../../utils/uploadToS3');
|
||||||
|
|
||||||
|
|
||||||
|
// Get user profile
|
||||||
|
exports.getProfile = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: req.user.id },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
username: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
phone: true,
|
||||||
|
avatar: true,
|
||||||
|
role: true,
|
||||||
|
isVerified: true,
|
||||||
|
createdAt: true,
|
||||||
|
lastLoginAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// res.json({ success: true, data: { user } });
|
||||||
|
return res.status(200).json({
|
||||||
|
statusCode: 200,
|
||||||
|
status: true,
|
||||||
|
message: 'Profile fetched successfully',
|
||||||
|
data: { user },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update user profile
|
||||||
|
exports.updateProfile = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { firstName, lastName, username, phone } = req.body;
|
||||||
|
|
||||||
|
if (username) {
|
||||||
|
const existingUser = await prisma.user.findFirst({
|
||||||
|
where: { username, NOT: { id: req.user.id } },
|
||||||
|
});
|
||||||
|
// if (existingUser) {
|
||||||
|
// return res.status(400).json({ success: false, message: 'Username already taken' });
|
||||||
|
// }
|
||||||
|
if (existingUser) {
|
||||||
|
return res.status(400).json({
|
||||||
|
statusCode: 400,
|
||||||
|
status: false,
|
||||||
|
message: 'Username already taken',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedUser = await prisma.user.update({
|
||||||
|
where: { id: req.user.id },
|
||||||
|
data: { firstName, lastName, username, phone },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
username: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
phone: true,
|
||||||
|
avatar: true,
|
||||||
|
role: true,
|
||||||
|
isVerified: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// res.json({ success: true, message: 'Profile updated successfully', data: { user: updatedUser } });
|
||||||
|
return res.status(200).json({
|
||||||
|
statusCode: 200,
|
||||||
|
status: true,
|
||||||
|
message: 'Profile updated successfully',
|
||||||
|
data: { user: updatedUser },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Upload avatar (profile picture)
|
||||||
|
exports.uploadAvatar = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
if (!req.file) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "No file uploaded",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const avatarUrl = req.file.location; // multer-s3 gives S3 URL
|
||||||
|
|
||||||
|
// Update avatar in Prisma
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: req.user.id },
|
||||||
|
data: { avatar: avatarUrl },
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: "Profile picture uploaded successfully",
|
||||||
|
avatarUrl, // only return the profile picture link
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
245
src/controllers/users/wishlistController.js
Normal file
245
src/controllers/users/wishlistController.js
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
const { prisma } = require('../../config/database');
|
||||||
|
// import Product from '../../models/mongodb/Product';
|
||||||
|
const Product = require('../../models/mongodb/Product');
|
||||||
|
|
||||||
|
|
||||||
|
// exports.getWishlist = async (req, res, next) => {
|
||||||
|
// try {
|
||||||
|
// const wishlist = await prisma.wishlistItem.findMany({
|
||||||
|
// where: { userId: req.user.id },
|
||||||
|
// orderBy: { createdAt: 'desc' },
|
||||||
|
// });
|
||||||
|
|
||||||
|
// // Fetch product details from MongoDB
|
||||||
|
// const detailedWishlist = await Promise.all(
|
||||||
|
// wishlist.map(async item => {
|
||||||
|
// const product = await Product.findById(item.productId).select(
|
||||||
|
// 'name basePrice variants images'
|
||||||
|
// );
|
||||||
|
|
||||||
|
// return {
|
||||||
|
// ...item,
|
||||||
|
// product: product || null,
|
||||||
|
// };
|
||||||
|
// })
|
||||||
|
// );
|
||||||
|
|
||||||
|
// // res.json({ success: true, data: { wishlist } });
|
||||||
|
// return res.status(200).json({
|
||||||
|
// // statusCode: 200,
|
||||||
|
// status: true,
|
||||||
|
// message: 'Wishlist fetched successfully',
|
||||||
|
// // data: { wishlist },
|
||||||
|
// data: { wishlist: detailedWishlist },
|
||||||
|
// });
|
||||||
|
// } catch (error) {
|
||||||
|
// next(error);
|
||||||
|
// }
|
||||||
|
// };
|
||||||
|
|
||||||
|
// exports.addToWishlist = async (req, res, next) => {
|
||||||
|
// try {
|
||||||
|
// const { productId } = req.body;
|
||||||
|
// // if (!productId) return res.status(400).json({ success: false, message: 'Product ID is required' });
|
||||||
|
|
||||||
|
// if (!productId) {
|
||||||
|
// return res.status(400).json({
|
||||||
|
// statusCode: 400,
|
||||||
|
// status: false,
|
||||||
|
// message: 'Product ID is required',
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
// const existing = await prisma.wishlistItem.findUnique({
|
||||||
|
// where: { userId_productId: { userId: req.user.id, productId } },
|
||||||
|
// });
|
||||||
|
// // if (existing) return res.status(400).json({ success: false, message: 'Item already in wishlist' });
|
||||||
|
// if (existing) {
|
||||||
|
// return res.status(400).json({
|
||||||
|
// statusCode: 400,
|
||||||
|
// status: false,
|
||||||
|
// message: 'Item already in wishlist',
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
// const item = await prisma.wishlistItem.create({
|
||||||
|
// data: { userId: req.user.id, productId },
|
||||||
|
// });
|
||||||
|
// // res.status(201).json({ success: true, message: 'Item added to wishlist', data: { item } });
|
||||||
|
// return res.status(201).json({
|
||||||
|
// statusCode: 201,
|
||||||
|
// status: true,
|
||||||
|
// message: 'Item added to wishlist',
|
||||||
|
// data: { item },
|
||||||
|
// });
|
||||||
|
// } catch (error) {
|
||||||
|
// next(error);
|
||||||
|
// }
|
||||||
|
// };
|
||||||
|
|
||||||
|
const mongoose = require("mongoose");
|
||||||
|
|
||||||
|
exports.getWishlist = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const wishlist = await prisma.wishlistItem.findMany({
|
||||||
|
where: { userId: req.user.id },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const detailedWishlist = await Promise.all(
|
||||||
|
wishlist.map(async (item) => {
|
||||||
|
let product = null;
|
||||||
|
|
||||||
|
// Only try MongoDB lookup if valid ObjectId
|
||||||
|
if (mongoose.Types.ObjectId.isValid(item.productId)) {
|
||||||
|
product = await Product.findById(item.productId).select(
|
||||||
|
"name basePrice variants images"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
product: product || null,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
status: true,
|
||||||
|
message: "Wishlist fetched successfully",
|
||||||
|
data: { wishlist: detailedWishlist },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// exports.addToWishlist = async (req, res, next) => {
|
||||||
|
// try {
|
||||||
|
// const { productId } = req.body;
|
||||||
|
|
||||||
|
// if (!productId) {
|
||||||
|
// return res.status(400).json({
|
||||||
|
// statusCode: 400,
|
||||||
|
// status: false,
|
||||||
|
// message: 'Product ID is required',
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
// const existing = await prisma.wishlistItem.findUnique({
|
||||||
|
// where: { userId_productId: { userId: req.user.id, productId } },
|
||||||
|
// });
|
||||||
|
|
||||||
|
// if (existing) {
|
||||||
|
// return res.status(400).json({
|
||||||
|
// statusCode: 400,
|
||||||
|
// status: false,
|
||||||
|
// message: 'Item already in wishlist',
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
// const item = await prisma.wishlistItem.create({
|
||||||
|
// data: { userId: req.user.id, productId },
|
||||||
|
// });
|
||||||
|
|
||||||
|
// return res.status(201).json({
|
||||||
|
// statusCode: 201,
|
||||||
|
// status: true,
|
||||||
|
// message: 'Item added to wishlist',
|
||||||
|
// data: { item },
|
||||||
|
// });
|
||||||
|
// } catch (error) {
|
||||||
|
// next(error);
|
||||||
|
// }
|
||||||
|
// };
|
||||||
|
|
||||||
|
|
||||||
|
// const mongoose = require("mongoose");
|
||||||
|
|
||||||
|
exports.addToWishlist = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { productId } = req.body;
|
||||||
|
|
||||||
|
if (!productId) {
|
||||||
|
return res.status(400).json({
|
||||||
|
statusCode: 400,
|
||||||
|
status: false,
|
||||||
|
message: "Product ID is required",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1️⃣ Validate ObjectId
|
||||||
|
if (!mongoose.Types.ObjectId.isValid(productId)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
status: false,
|
||||||
|
message: "Invalid product ID (must be MongoDB ObjectId)",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2️⃣ Ensure product exists in MongoDB
|
||||||
|
const productExists = await Product.findById(productId);
|
||||||
|
if (!productExists) {
|
||||||
|
return res.status(404).json({
|
||||||
|
status: false,
|
||||||
|
message: "Product not found in database",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3️⃣ Check duplicate
|
||||||
|
const existing = await prisma.wishlistItem.findUnique({
|
||||||
|
where: { userId_productId: { userId: req.user.id, productId } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
return res.status(400).json({
|
||||||
|
status: false,
|
||||||
|
message: "Item already in wishlist",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4️⃣ Save VALID productId in Prisma
|
||||||
|
const item = await prisma.wishlistItem.create({
|
||||||
|
data: { userId: req.user.id, productId },
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(201).json({
|
||||||
|
status: true,
|
||||||
|
message: "Item added to wishlist",
|
||||||
|
data: { item },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
exports.removeFromWishlist = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { productId } = req.params;
|
||||||
|
const existing = await prisma.wishlistItem.findUnique({
|
||||||
|
where: { userId_productId: { userId: req.user.id, productId } },
|
||||||
|
});
|
||||||
|
// if (!existing) return res.status(404).json({ success: false, message: 'Item not found' });
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return res.status(404).json({
|
||||||
|
statusCode: 404,
|
||||||
|
status: false,
|
||||||
|
message: 'Item not found in wishlist',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.wishlistItem.delete({
|
||||||
|
where: { userId_productId: { userId: req.user.id, productId } },
|
||||||
|
});
|
||||||
|
// res.json({ success: true, message: 'Item removed from wishlist' });
|
||||||
|
return res.status(200).json({
|
||||||
|
statusCode: 200,
|
||||||
|
status: true,
|
||||||
|
message: 'Item removed from wishlist',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
200
src/controllers/wardrobe/wardrobeItemController.js
Normal file
200
src/controllers/wardrobe/wardrobeItemController.js
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
const Wardrobe = require('../../models/mongodb/Wardrobe');
|
||||||
|
|
||||||
|
// @desc Add item to wardrobe
|
||||||
|
// exports.addItem = async (req, res, next) => {
|
||||||
|
// try {
|
||||||
|
// const itemData = req.body;
|
||||||
|
|
||||||
|
// let wardrobe = await Wardrobe.findByUserId(req.user.id);
|
||||||
|
|
||||||
|
// if (!wardrobe) {
|
||||||
|
// wardrobe = new Wardrobe({ userId: req.user.id, name: 'My Wardrobe' });
|
||||||
|
// }
|
||||||
|
|
||||||
|
// await wardrobe.addItem(itemData);
|
||||||
|
|
||||||
|
// // res.status(201).json({
|
||||||
|
// // success: true,
|
||||||
|
// // message: 'Item added successfully',
|
||||||
|
// // data: { wardrobe },
|
||||||
|
// // });
|
||||||
|
// return res.status(201).json({
|
||||||
|
// statusCode: 201,
|
||||||
|
// status: true,
|
||||||
|
// message: 'Item added successfully',
|
||||||
|
// data: { wardrobe },
|
||||||
|
// });
|
||||||
|
// } catch (error) {
|
||||||
|
// next(error);
|
||||||
|
// }
|
||||||
|
// };
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
exports.addItem = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const itemData = { ...req.body };
|
||||||
|
|
||||||
|
// ✅ FIX: map image → images array
|
||||||
|
if (itemData.image) {
|
||||||
|
itemData.images = [
|
||||||
|
{
|
||||||
|
url: itemData.image,
|
||||||
|
isPrimary: true,
|
||||||
|
}
|
||||||
|
];
|
||||||
|
delete itemData.image; // prevent schema pollution
|
||||||
|
}
|
||||||
|
|
||||||
|
let wardrobe = await Wardrobe.findByUserId(req.user.id);
|
||||||
|
|
||||||
|
if (!wardrobe) {
|
||||||
|
wardrobe = new Wardrobe({
|
||||||
|
userId: req.user.id,
|
||||||
|
name: 'My Wardrobe',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await wardrobe.addItem(itemData);
|
||||||
|
|
||||||
|
return res.status(201).json({
|
||||||
|
statusCode: 201,
|
||||||
|
status: true,
|
||||||
|
message: 'Item added successfully',
|
||||||
|
data: { wardrobe },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// @desc Update wardrobe item
|
||||||
|
exports.updateItem = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { itemId } = req.params;
|
||||||
|
const updateData = req.body;
|
||||||
|
const wardrobe = await Wardrobe.findByUserId(req.user.id);
|
||||||
|
|
||||||
|
// if (!wardrobe) {
|
||||||
|
// return res.status(404).json({ success: false, message: 'Wardrobe not found' });
|
||||||
|
// }
|
||||||
|
|
||||||
|
if (!wardrobe) {
|
||||||
|
return res.status(404).json({
|
||||||
|
statusCode: 404,
|
||||||
|
status: false,
|
||||||
|
message: 'Wardrobe not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await wardrobe.updateItem(itemId, updateData);
|
||||||
|
|
||||||
|
// res.json({ success: true, message: 'Item updated successfully', data: { wardrobe } });
|
||||||
|
return res.json({
|
||||||
|
statusCode: 200,
|
||||||
|
status: true,
|
||||||
|
message: 'Item updated successfully',
|
||||||
|
data: { wardrobe },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// @desc Remove item from wardrobe
|
||||||
|
exports.removeItem = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { itemId } = req.params;
|
||||||
|
const wardrobe = await Wardrobe.findByUserId(req.user.id);
|
||||||
|
|
||||||
|
// if (!wardrobe) {
|
||||||
|
// return res.status(404).json({ success: false, message: 'Wardrobe not found' });
|
||||||
|
// }
|
||||||
|
|
||||||
|
if (!wardrobe) {
|
||||||
|
return res.status(404).json({
|
||||||
|
statusCode: 404,
|
||||||
|
status: false,
|
||||||
|
message: 'Wardrobe not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await wardrobe.removeItem(itemId);
|
||||||
|
|
||||||
|
// res.json({ success: true, message: 'Item removed successfully', data: { wardrobe } });
|
||||||
|
return res.json({
|
||||||
|
statusCode: 200,
|
||||||
|
status: true,
|
||||||
|
message: 'Item removed successfully',
|
||||||
|
data: { wardrobe },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// @desc Get items by category
|
||||||
|
// exports.getItemsByCategory = async (req, res, next) => {
|
||||||
|
// try {
|
||||||
|
// const { category } = req.params;
|
||||||
|
// const wardrobe = await Wardrobe.findByUserId(req.user.id);
|
||||||
|
|
||||||
|
// // if (!wardrobe) return res.status(404).json({ success: false, message: 'Wardrobe not found' });
|
||||||
|
|
||||||
|
// if (!wardrobe) {
|
||||||
|
// return res.status(404).json({
|
||||||
|
// statusCode: 404,
|
||||||
|
// status: false,
|
||||||
|
// message: 'Wardrobe not found',
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
// const items = wardrobe.getItemsByCategory(category);
|
||||||
|
// // res.json({ success: true, data: { items } });
|
||||||
|
// return res.json({
|
||||||
|
// statusCode: 200,
|
||||||
|
// status: true,
|
||||||
|
// message: 'Items fetched successfully',
|
||||||
|
// data: { items },
|
||||||
|
// });
|
||||||
|
// } catch (error) {
|
||||||
|
// next(error);
|
||||||
|
// }
|
||||||
|
// };
|
||||||
|
|
||||||
|
|
||||||
|
// controllers/wardrobeController.js
|
||||||
|
exports.getItemsByCategory = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { category } = req.params;
|
||||||
|
const wardrobe = await Wardrobe.findByUserId(req.user.id);
|
||||||
|
|
||||||
|
if (!wardrobe) {
|
||||||
|
return res.status(404).json({
|
||||||
|
statusCode: 404,
|
||||||
|
status: false,
|
||||||
|
message: "Wardrobe not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = wardrobe.getItemsByCategory(category) || [];
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
statusCode: 200,
|
||||||
|
status: true,
|
||||||
|
message: "Items fetched successfully",
|
||||||
|
data: { items },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching items by category:", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
statusCode: 500,
|
||||||
|
status: false,
|
||||||
|
message: "Internal server error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
66
src/controllers/wardrobe/wardrobeMainController.js
Normal file
66
src/controllers/wardrobe/wardrobeMainController.js
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
const Wardrobe = require('../../models/mongodb/Wardrobe');
|
||||||
|
|
||||||
|
// @desc Get user's wardrobe
|
||||||
|
exports.getWardrobe = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
let wardrobe = await Wardrobe.findByUserId(req.user.id);
|
||||||
|
|
||||||
|
if (!wardrobe) {
|
||||||
|
wardrobe = new Wardrobe({
|
||||||
|
userId: req.user.id,
|
||||||
|
name: 'My Wardrobe',
|
||||||
|
});
|
||||||
|
await wardrobe.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
// res.json({ success: true, data: { wardrobe } });
|
||||||
|
return res.status(200).json({
|
||||||
|
statusCode: 200,
|
||||||
|
status: true,
|
||||||
|
message: "Wardrobe fetched successfully",
|
||||||
|
data: { wardrobe },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// @desc Update wardrobe details
|
||||||
|
exports.updateWardrobe = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { name, description, isPublic, shareSettings } = req.body;
|
||||||
|
|
||||||
|
let wardrobe = await Wardrobe.findByUserId(req.user.id);
|
||||||
|
|
||||||
|
if (!wardrobe) {
|
||||||
|
wardrobe = new Wardrobe({
|
||||||
|
userId: req.user.id,
|
||||||
|
name: name || 'My Wardrobe',
|
||||||
|
description,
|
||||||
|
isPublic: isPublic || false,
|
||||||
|
shareSettings: shareSettings || { allowViewing: false, allowRecommendations: false },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
wardrobe.name = name || wardrobe.name;
|
||||||
|
wardrobe.description = description;
|
||||||
|
wardrobe.isPublic = isPublic ?? wardrobe.isPublic;
|
||||||
|
wardrobe.shareSettings = shareSettings || wardrobe.shareSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
await wardrobe.save();
|
||||||
|
|
||||||
|
// res.json({
|
||||||
|
// success: true,
|
||||||
|
// message: 'Wardrobe updated successfully',
|
||||||
|
// data: { wardrobe },
|
||||||
|
// });
|
||||||
|
return res.status(200).json({
|
||||||
|
statusCode: 200,
|
||||||
|
status: true,
|
||||||
|
message: "Wardrobe updated successfully",
|
||||||
|
data: { wardrobe },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
48
src/controllers/wardrobe/wardrobePublicController.js
Normal file
48
src/controllers/wardrobe/wardrobePublicController.js
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
const Wardrobe = require('../../models/mongodb/Wardrobe');
|
||||||
|
|
||||||
|
// @desc Get public wardrobes
|
||||||
|
exports.getPublicWardrobes = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { page = 1, limit = 20 } = req.query;
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
|
const wardrobes = await Wardrobe.findPublicWardrobes(parseInt(limit), parseInt(skip));
|
||||||
|
|
||||||
|
// res.json({ success: true, data: { wardrobes } });
|
||||||
|
return res.status(200).json({
|
||||||
|
statusCode: 200,
|
||||||
|
status: true,
|
||||||
|
message: "Public wardrobes fetched successfully",
|
||||||
|
data: { wardrobes },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// @desc Get public wardrobe by ID
|
||||||
|
exports.getPublicWardrobeById = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const wardrobe = await Wardrobe.findOne({ _id: req.params.id, isPublic: true });
|
||||||
|
|
||||||
|
// if (!wardrobe) return res.status(404).json({ success: false, message: 'Wardrobe not found' });
|
||||||
|
|
||||||
|
if (!wardrobe) {
|
||||||
|
return res.status(404).json({
|
||||||
|
statusCode: 404,
|
||||||
|
status: false,
|
||||||
|
message: "Wardrobe not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// res.json({ success: true, data: { wardrobe } });
|
||||||
|
return res.status(200).json({
|
||||||
|
statusCode: 200,
|
||||||
|
status: true,
|
||||||
|
message: "Public wardrobe fetched successfully",
|
||||||
|
data: { wardrobe },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
29
src/controllers/wardrobe/wardrobeRecommendationController.js
Normal file
29
src/controllers/wardrobe/wardrobeRecommendationController.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
const Wardrobe = require('../../models/mongodb/Wardrobe');
|
||||||
|
|
||||||
|
// @desc Generate outfit recommendations
|
||||||
|
exports.getRecommendations = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const wardrobe = await Wardrobe.findByUserId(req.user.id);
|
||||||
|
// if (!wardrobe) return res.status(404).json({ success: false, message: 'Wardrobe not found' });
|
||||||
|
|
||||||
|
if (!wardrobe) {
|
||||||
|
return res.status(404).json({
|
||||||
|
statusCode: 404,
|
||||||
|
status: false,
|
||||||
|
message: "Wardrobe not found",
|
||||||
|
data: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const recommendations = wardrobe.generateOutfitRecommendations();
|
||||||
|
// res.json({ success: true, data: { recommendations } });
|
||||||
|
return res.status(200).json({
|
||||||
|
statusCode: 200,
|
||||||
|
status: true,
|
||||||
|
message: "Recommendations fetched successfully",
|
||||||
|
data: { recommendations }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
52
src/controllers/wardrobe/wardrobeSearchController.js
Normal file
52
src/controllers/wardrobe/wardrobeSearchController.js
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
const Wardrobe = require('../../models/mongodb/Wardrobe');
|
||||||
|
|
||||||
|
// @desc Search wardrobe items
|
||||||
|
exports.searchItems = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { query, tags, category } = req.query;
|
||||||
|
const wardrobe = await Wardrobe.findByUserId(req.user.id);
|
||||||
|
|
||||||
|
// if (!wardrobe) return res.status(404).json({ success: false, message: 'Wardrobe not found' });
|
||||||
|
|
||||||
|
if (!wardrobe) {
|
||||||
|
return res.status(404).json({
|
||||||
|
statusCode: 404,
|
||||||
|
status: false,
|
||||||
|
message: "Wardrobe not found",
|
||||||
|
data: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let items = wardrobe.items.filter(item => item.isActive);
|
||||||
|
|
||||||
|
if (category) items = items.filter(item => item.category === category);
|
||||||
|
|
||||||
|
if (tags) {
|
||||||
|
const tagArray = tags.split(',');
|
||||||
|
items = items.filter(item =>
|
||||||
|
tagArray.some(tag =>
|
||||||
|
item.aiTags.includes(tag) || item.userTags.includes(tag)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query) {
|
||||||
|
const searchTerm = query.toLowerCase();
|
||||||
|
items = items.filter(item =>
|
||||||
|
item.name.toLowerCase().includes(searchTerm) ||
|
||||||
|
item.brand?.toLowerCase().includes(searchTerm) ||
|
||||||
|
item.description?.toLowerCase().includes(searchTerm)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// res.json({ success: true, data: { items } });
|
||||||
|
return res.status(200).json({
|
||||||
|
statusCode: 200,
|
||||||
|
status: true,
|
||||||
|
message: "Items fetched successfully",
|
||||||
|
data: { items }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
39
src/controllers/wardrobe/wardrobeStatsController.js
Normal file
39
src/controllers/wardrobe/wardrobeStatsController.js
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
const Wardrobe = require('../../models/mongodb/Wardrobe');
|
||||||
|
|
||||||
|
// @desc Get wardrobe statistics
|
||||||
|
exports.getStats = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const wardrobe = await Wardrobe.findByUserId(req.user.id);
|
||||||
|
// if (!wardrobe) return res.status(404).json({ success: false, message: 'Wardrobe not found' });
|
||||||
|
|
||||||
|
if (!wardrobe) {
|
||||||
|
return res.status(404).json({
|
||||||
|
statusCode: 404,
|
||||||
|
status: false,
|
||||||
|
message: "Wardrobe not found",
|
||||||
|
data: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = {
|
||||||
|
totalItems: wardrobe.totalItems,
|
||||||
|
categoryCounts: wardrobe.categoryCounts,
|
||||||
|
recentItems: wardrobe.items
|
||||||
|
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
|
||||||
|
.slice(0, 5),
|
||||||
|
mostWornItems: wardrobe.items
|
||||||
|
.sort((a, b) => (b.purchaseCount || 0) - (a.purchaseCount || 0))
|
||||||
|
.slice(0, 5),
|
||||||
|
};
|
||||||
|
|
||||||
|
// res.json({ success: true, data: { stats } });
|
||||||
|
return res.status(200).json({
|
||||||
|
statusCode: 200,
|
||||||
|
status: true,
|
||||||
|
message: "Wardrobe statistics fetched successfully",
|
||||||
|
data: { stats }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
153
src/middleware/auth.js
Normal file
153
src/middleware/auth.js
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
const { prisma } = require('../config/database');
|
||||||
|
|
||||||
|
// Protect routes - require authentication
|
||||||
|
const protect = async (req, res, next) => {
|
||||||
|
let token;
|
||||||
|
|
||||||
|
// Check for token in header
|
||||||
|
if (req.headers.authorization && req.headers.authorization.startsWith('Bearer')) {
|
||||||
|
try {
|
||||||
|
// Get token from header
|
||||||
|
token = req.headers.authorization.split(' ')[1];
|
||||||
|
|
||||||
|
// Verify token
|
||||||
|
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||||
|
|
||||||
|
// Get user from database
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: decoded.id },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
username: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
role: true,
|
||||||
|
isActive: true,
|
||||||
|
isVerified: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Not authorized, user not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.isActive) {
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Not authorized, account is deactivated',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
req.user = user;
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Auth middleware error:', error);
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Not authorized, token failed',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Not authorized, no token',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Grant access to specific roles
|
||||||
|
const authorize = (...roles) => {
|
||||||
|
return (req, res, next) => {
|
||||||
|
if (!req.user) {
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Not authorized to access this route',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!roles.includes(req.user.role)) {
|
||||||
|
return res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
message: `User role ${req.user.role} is not authorized to access this route`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Optional auth - doesn't fail if no token
|
||||||
|
const optionalAuth = async (req, res, next) => {
|
||||||
|
let token;
|
||||||
|
|
||||||
|
if (req.headers.authorization && req.headers.authorization.startsWith('Bearer')) {
|
||||||
|
try {
|
||||||
|
token = req.headers.authorization.split(' ')[1];
|
||||||
|
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: decoded.id },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
username: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
role: true,
|
||||||
|
isActive: true,
|
||||||
|
isVerified: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (user && user.isActive) {
|
||||||
|
req.user = user;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore token errors for optional auth
|
||||||
|
console.log('Optional auth token error:', error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if user owns resource
|
||||||
|
const checkOwnership = (resourceUserIdField = 'userId') => {
|
||||||
|
return (req, res, next) => {
|
||||||
|
if (!req.user) {
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Not authorized',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin can access any resource
|
||||||
|
if (req.user.role === 'ADMIN') {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user owns the resource
|
||||||
|
const resourceUserId = req.params[resourceUserIdField] || req.body[resourceUserIdField];
|
||||||
|
|
||||||
|
if (resourceUserId && resourceUserId !== req.user.id) {
|
||||||
|
return res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Not authorized to access this resource',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
protect,
|
||||||
|
authorize,
|
||||||
|
optionalAuth,
|
||||||
|
checkOwnership,
|
||||||
|
};
|
||||||
61
src/middleware/errorHandler.js
Normal file
61
src/middleware/errorHandler.js
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
const errorHandler = (err, req, res, next) => {
|
||||||
|
let error = { ...err };
|
||||||
|
error.message = err.message;
|
||||||
|
|
||||||
|
// Log error
|
||||||
|
console.error(err);
|
||||||
|
|
||||||
|
// Mongoose bad ObjectId
|
||||||
|
if (err.name === 'CastError') {
|
||||||
|
const message = 'Resource not found';
|
||||||
|
error = { message, statusCode: 404 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mongoose duplicate key
|
||||||
|
if (err.code === 11000) {
|
||||||
|
const message = 'Duplicate field value entered';
|
||||||
|
error = { message, statusCode: 400 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mongoose validation error
|
||||||
|
if (err.name === 'ValidationError') {
|
||||||
|
const message = Object.values(err.errors).map(val => val.message);
|
||||||
|
error = { message, statusCode: 400 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prisma errors
|
||||||
|
if (err.code === 'P2002') {
|
||||||
|
const message = 'Duplicate field value entered';
|
||||||
|
error = { message, statusCode: 400 };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (err.code === 'P2025') {
|
||||||
|
const message = 'Record not found';
|
||||||
|
error = { message, statusCode: 404 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// JWT errors
|
||||||
|
if (err.name === 'JsonWebTokenError') {
|
||||||
|
const message = 'Invalid token';
|
||||||
|
error = { message, statusCode: 401 };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (err.name === 'TokenExpiredError') {
|
||||||
|
const message = 'Token expired';
|
||||||
|
error = { message, statusCode: 401 };
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(error.statusCode || 500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message || 'Server Error',
|
||||||
|
...(process.env.NODE_ENV === 'development' && { stack: err.stack }),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const notFound = (req, res, next) => {
|
||||||
|
const error = new Error(`Not Found - ${req.originalUrl}`);
|
||||||
|
res.status(404);
|
||||||
|
next(error);
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = { errorHandler, notFound };
|
||||||
10
src/middleware/upload.js
Normal file
10
src/middleware/upload.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
const multer = require("multer");
|
||||||
|
|
||||||
|
const upload = multer({
|
||||||
|
storage: multer.memoryStorage(),
|
||||||
|
limits: { fileSize: 5 * 1024 * 1024 },
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = upload;
|
||||||
|
|
||||||
|
|
||||||
25
src/middleware/uploadProfile.js
Normal file
25
src/middleware/uploadProfile.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
const multerS3 = require("multer-s3");
|
||||||
|
const multer = require("multer");
|
||||||
|
const s3 = require("../config/s3");
|
||||||
|
|
||||||
|
const uploadProfile = multer({
|
||||||
|
storage: multerS3({
|
||||||
|
s3: s3,
|
||||||
|
bucket: process.env.AWS_S3_BUCKET,
|
||||||
|
acl: "public-read",
|
||||||
|
key: (req, file, cb) => {
|
||||||
|
const userId = req.user.id;
|
||||||
|
const ext = file.originalname.split(".").pop();
|
||||||
|
cb(null, `profiles/${userId}-${Date.now()}.${ext}`);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
limits: { fileSize: 5 * 1024 * 1024 },
|
||||||
|
fileFilter: (req, file, cb) => {
|
||||||
|
if (!file.mimetype.startsWith("image/")) {
|
||||||
|
return cb(new Error("Only images are allowed!"));
|
||||||
|
}
|
||||||
|
cb(null, true);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = { uploadProfile };
|
||||||
310
src/models/mongodb/Product.js
Normal file
310
src/models/mongodb/Product.js
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
const mongoose = require('mongoose');
|
||||||
|
|
||||||
|
const variantSchema = new mongoose.Schema({
|
||||||
|
size: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
color: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
sku: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
price: {
|
||||||
|
type: Number,
|
||||||
|
required: true,
|
||||||
|
min: 0,
|
||||||
|
},
|
||||||
|
compareAtPrice: {
|
||||||
|
type: Number,
|
||||||
|
min: 0,
|
||||||
|
},
|
||||||
|
inventory: {
|
||||||
|
quantity: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
min: 0,
|
||||||
|
},
|
||||||
|
|
||||||
|
trackInventory: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
images: [String],
|
||||||
|
isActive: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const productSchema = new mongoose.Schema({
|
||||||
|
// Basic Information
|
||||||
|
name: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
trim: true,
|
||||||
|
},
|
||||||
|
slug: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
unique: true,
|
||||||
|
lowercase: true,
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
shortDescription: {
|
||||||
|
type: String,
|
||||||
|
maxLength: 500,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Categorization
|
||||||
|
category: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
subcategory: String,
|
||||||
|
tags: [String],
|
||||||
|
brand: String,
|
||||||
|
|
||||||
|
// Pricing & Inventory
|
||||||
|
basePrice: {
|
||||||
|
type: Number,
|
||||||
|
required: true,
|
||||||
|
min: 0,
|
||||||
|
},
|
||||||
|
compareAtPrice: {
|
||||||
|
type: Number,
|
||||||
|
min: 0,
|
||||||
|
},
|
||||||
|
costPrice: {
|
||||||
|
type: Number,
|
||||||
|
min: 0,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Variants
|
||||||
|
variants: [variantSchema],
|
||||||
|
hasVariants: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Media
|
||||||
|
images: {
|
||||||
|
primary: String,
|
||||||
|
gallery: [String],
|
||||||
|
videos: [String],
|
||||||
|
},
|
||||||
|
|
||||||
|
// SEO
|
||||||
|
metaTitle: String,
|
||||||
|
metaDescription: String,
|
||||||
|
metaKeywords: [String],
|
||||||
|
|
||||||
|
// Status & Visibility
|
||||||
|
status: {
|
||||||
|
type: String,
|
||||||
|
enum: ['draft', 'active', 'inactive', 'archived'],
|
||||||
|
default: 'draft',
|
||||||
|
},
|
||||||
|
isFeatured: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
isDigital: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Physical Attributes
|
||||||
|
weight: {
|
||||||
|
value: Number,
|
||||||
|
unit: {
|
||||||
|
type: String,
|
||||||
|
enum: ['g', 'kg', 'lb', 'oz'],
|
||||||
|
default: 'g',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
dimensions: {
|
||||||
|
length: Number,
|
||||||
|
width: Number,
|
||||||
|
height: Number,
|
||||||
|
unit: {
|
||||||
|
type: String,
|
||||||
|
enum: ['cm', 'in'],
|
||||||
|
default: 'cm',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Analytics
|
||||||
|
viewCount: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
purchaseCount: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
|
||||||
|
// AI Generated Tags
|
||||||
|
aiTags: [String],
|
||||||
|
aiGeneratedDescription: String,
|
||||||
|
|
||||||
|
// Timestamps
|
||||||
|
createdAt: {
|
||||||
|
type: Date,
|
||||||
|
default: Date.now,
|
||||||
|
},
|
||||||
|
updatedAt: {
|
||||||
|
type: Date,
|
||||||
|
default: Date.now,
|
||||||
|
},
|
||||||
|
publishedAt: Date,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Indexes for better performance
|
||||||
|
productSchema.index({ slug: 1 });
|
||||||
|
productSchema.index({ category: 1, status: 1 });
|
||||||
|
productSchema.index({ brand: 1 });
|
||||||
|
productSchema.index({ tags: 1 });
|
||||||
|
productSchema.index({ 'variants.sku': 1 });
|
||||||
|
productSchema.index({ status: 1, isFeatured: 1 });
|
||||||
|
productSchema.index({ createdAt: -1 });
|
||||||
|
productSchema.index({ stock: 1 });
|
||||||
|
|
||||||
|
// Text search index
|
||||||
|
productSchema.index({
|
||||||
|
name: 'text',
|
||||||
|
description: 'text',
|
||||||
|
tags: 'text',
|
||||||
|
brand: 'text',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Virtual for average rating (if implementing ratings)
|
||||||
|
productSchema.virtual('averageRating').get(function () {
|
||||||
|
// This would be calculated from reviews in PostgreSQL
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pre-save middleware
|
||||||
|
productSchema.pre('save', function (next) {
|
||||||
|
this.updatedAt = new Date();
|
||||||
|
|
||||||
|
// Auto-generate slug if not provided
|
||||||
|
if (!this.slug && this.name) {
|
||||||
|
this.slug = this.name
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/(^-|-$)/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set published date when status changes to active
|
||||||
|
if (
|
||||||
|
this.isModified('status') &&
|
||||||
|
this.status === 'active' &&
|
||||||
|
!this.publishedAt
|
||||||
|
) {
|
||||||
|
this.publishedAt = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Instance methods
|
||||||
|
productSchema.methods.incrementViewCount = function () {
|
||||||
|
this.viewCount += 1;
|
||||||
|
return this.save();
|
||||||
|
};
|
||||||
|
|
||||||
|
productSchema.methods.incrementPurchaseCount = function (quantity = 1) {
|
||||||
|
this.purchaseCount += quantity;
|
||||||
|
return this.save();
|
||||||
|
};
|
||||||
|
|
||||||
|
productSchema.methods.getAvailableVariants = function () {
|
||||||
|
return this.variants.filter(
|
||||||
|
variant =>
|
||||||
|
variant.isActive &&
|
||||||
|
(!variant.inventory.trackInventory || variant.inventory.quantity > 0)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
productSchema.methods.getAvailableStock = function () {
|
||||||
|
if (this.hasVariants && this.variants?.length > 0) {
|
||||||
|
return this.variants
|
||||||
|
.filter(v => v.isActive)
|
||||||
|
.reduce((sum, v) => sum + (v.inventory?.quantity || 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.stock || 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
productSchema.methods.isInStock = function (quantity = 1) {
|
||||||
|
const availableStock = this.getAvailableStock();
|
||||||
|
return availableStock >= quantity;
|
||||||
|
};
|
||||||
|
|
||||||
|
productSchema.methods.reduceStock = async function (quantity = 1) {
|
||||||
|
if (this.hasVariants && this.variants && this.variants.length > 0) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: 'Use variant-specific stock reduction',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentStock = this.stock || 0;
|
||||||
|
this.stock = Math.max(0, currentStock - quantity);
|
||||||
|
await this.save();
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
previousStock: currentStock,
|
||||||
|
newStock: this.stock,
|
||||||
|
reduced: quantity,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Static methods
|
||||||
|
productSchema.statics.findBySlug = function (slug) {
|
||||||
|
return this.findOne({ slug, status: 'active' });
|
||||||
|
};
|
||||||
|
|
||||||
|
productSchema.statics.findByCategory = function (
|
||||||
|
category,
|
||||||
|
limit = 20,
|
||||||
|
skip = 0
|
||||||
|
) {
|
||||||
|
return this.find({ category, status: 'active' })
|
||||||
|
.limit(limit)
|
||||||
|
.skip(skip)
|
||||||
|
.sort({ createdAt: -1 });
|
||||||
|
};
|
||||||
|
|
||||||
|
productSchema.statics.searchProducts = function (query, options = {}) {
|
||||||
|
const { category, brand, minPrice, maxPrice, limit = 20, skip = 0 } = options;
|
||||||
|
|
||||||
|
const searchQuery = {
|
||||||
|
$text: { $search: query },
|
||||||
|
status: 'active',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (category) searchQuery.category = category;
|
||||||
|
if (brand) searchQuery.brand = brand;
|
||||||
|
if (minPrice || maxPrice) {
|
||||||
|
searchQuery.basePrice = {};
|
||||||
|
if (minPrice) searchQuery.basePrice.$gte = minPrice;
|
||||||
|
if (maxPrice) searchQuery.basePrice.$lte = maxPrice;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.find(searchQuery)
|
||||||
|
.limit(limit)
|
||||||
|
.skip(skip)
|
||||||
|
.sort({ score: { $meta: 'textScore' } });
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = mongoose.model('Product', productSchema);
|
||||||
254
src/models/mongodb/Wardrobe.js
Normal file
254
src/models/mongodb/Wardrobe.js
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
const mongoose = require('mongoose');
|
||||||
|
|
||||||
|
const wardrobeItemSchema = new mongoose.Schema({
|
||||||
|
// Basic Information
|
||||||
|
name: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
description: String,
|
||||||
|
|
||||||
|
// Category & Type
|
||||||
|
category: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
enum: ['tops', 'bottoms', 'dresses', 'outerwear', 'shoes', 'accessories', 'other'],
|
||||||
|
},
|
||||||
|
subcategory: String,
|
||||||
|
brand: String,
|
||||||
|
color: String,
|
||||||
|
|
||||||
|
// Images
|
||||||
|
images: [{
|
||||||
|
url: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
isPrimary: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
uploadedAt: {
|
||||||
|
type: Date,
|
||||||
|
default: Date.now,
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
|
||||||
|
// AI Generated Tags
|
||||||
|
aiTags: [String],
|
||||||
|
aiColorPalette: [String],
|
||||||
|
aiStyleTags: [String],
|
||||||
|
|
||||||
|
// User Tags
|
||||||
|
userTags: [String],
|
||||||
|
|
||||||
|
// Physical Attributes
|
||||||
|
size: String,
|
||||||
|
material: String,
|
||||||
|
condition: {
|
||||||
|
type: String,
|
||||||
|
enum: ['new', 'like-new', 'good', 'fair', 'poor'],
|
||||||
|
default: 'good',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Status
|
||||||
|
isActive: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Timestamps
|
||||||
|
createdAt: {
|
||||||
|
type: Date,
|
||||||
|
default: Date.now,
|
||||||
|
},
|
||||||
|
updatedAt: {
|
||||||
|
type: Date,
|
||||||
|
default: Date.now,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const wardrobeSchema = new mongoose.Schema({
|
||||||
|
userId: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
ref: 'User', // Reference to PostgreSQL User
|
||||||
|
},
|
||||||
|
|
||||||
|
// Wardrobe Information
|
||||||
|
name: {
|
||||||
|
type: String,
|
||||||
|
default: 'My Wardrobe',
|
||||||
|
},
|
||||||
|
description: String,
|
||||||
|
|
||||||
|
// Items
|
||||||
|
items: [wardrobeItemSchema],
|
||||||
|
|
||||||
|
// Statistics
|
||||||
|
totalItems: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
categoryCounts: {
|
||||||
|
tops: { type: Number, default: 0 },
|
||||||
|
bottoms: { type: Number, default: 0 },
|
||||||
|
dresses: { type: Number, default: 0 },
|
||||||
|
outerwear: { type: Number, default: 0 },
|
||||||
|
shoes: { type: Number, default: 0 },
|
||||||
|
accessories: { type: Number, default: 0 },
|
||||||
|
other: { type: Number, default: 0 },
|
||||||
|
},
|
||||||
|
|
||||||
|
// AI Analysis
|
||||||
|
aiAnalysis: {
|
||||||
|
lastAnalyzed: Date,
|
||||||
|
dominantColors: [String],
|
||||||
|
styleProfile: {
|
||||||
|
casual: Number,
|
||||||
|
formal: Number,
|
||||||
|
trendy: Number,
|
||||||
|
classic: Number,
|
||||||
|
bohemian: Number,
|
||||||
|
minimalist: Number,
|
||||||
|
},
|
||||||
|
recommendations: [String],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Privacy Settings
|
||||||
|
isPublic: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
shareSettings: {
|
||||||
|
allowViewing: Boolean,
|
||||||
|
allowRecommendations: Boolean,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Timestamps
|
||||||
|
createdAt: {
|
||||||
|
type: Date,
|
||||||
|
default: Date.now,
|
||||||
|
},
|
||||||
|
updatedAt: {
|
||||||
|
type: Date,
|
||||||
|
default: Date.now,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Indexes
|
||||||
|
wardrobeSchema.index({ userId: 1 });
|
||||||
|
wardrobeSchema.index({ isPublic: 1 });
|
||||||
|
wardrobeSchema.index({ 'items.category': 1 });
|
||||||
|
wardrobeSchema.index({ 'items.aiTags': 1 });
|
||||||
|
|
||||||
|
// Text search index
|
||||||
|
wardrobeSchema.index({
|
||||||
|
name: 'text',
|
||||||
|
description: 'text',
|
||||||
|
'items.name': 'text',
|
||||||
|
'items.brand': 'text',
|
||||||
|
'items.userTags': 'text',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pre-save middleware
|
||||||
|
wardrobeSchema.pre('save', function(next) {
|
||||||
|
this.updatedAt = new Date();
|
||||||
|
|
||||||
|
// Update statistics
|
||||||
|
this.totalItems = this.items.length;
|
||||||
|
|
||||||
|
// Reset category counts
|
||||||
|
this.categoryCounts = {
|
||||||
|
tops: 0,
|
||||||
|
bottoms: 0,
|
||||||
|
dresses: 0,
|
||||||
|
outerwear: 0,
|
||||||
|
shoes: 0,
|
||||||
|
accessories: 0,
|
||||||
|
other: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Count items by category
|
||||||
|
this.items.forEach(item => {
|
||||||
|
if (this.categoryCounts[item.category] !== undefined) {
|
||||||
|
this.categoryCounts[item.category]++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Instance methods
|
||||||
|
wardrobeSchema.methods.addItem = function(itemData) {
|
||||||
|
this.items.push(itemData);
|
||||||
|
return this.save();
|
||||||
|
};
|
||||||
|
|
||||||
|
wardrobeSchema.methods.removeItem = function(itemId) {
|
||||||
|
this.items = this.items.filter(item => item._id.toString() !== itemId);
|
||||||
|
return this.save();
|
||||||
|
};
|
||||||
|
|
||||||
|
wardrobeSchema.methods.updateItem = function(itemId, updateData) {
|
||||||
|
const item = this.items.id(itemId);
|
||||||
|
if (item) {
|
||||||
|
Object.assign(item, updateData);
|
||||||
|
item.updatedAt = new Date();
|
||||||
|
return this.save();
|
||||||
|
}
|
||||||
|
throw new Error('Item not found');
|
||||||
|
};
|
||||||
|
|
||||||
|
wardrobeSchema.methods.getItemsByCategory = function(category) {
|
||||||
|
return this.items.filter(item => item.category === category && item.isActive);
|
||||||
|
};
|
||||||
|
|
||||||
|
wardrobeSchema.methods.getItemsByTags = function(tags) {
|
||||||
|
return this.items.filter(item =>
|
||||||
|
item.isActive &&
|
||||||
|
tags.some(tag =>
|
||||||
|
item.aiTags.includes(tag) ||
|
||||||
|
item.userTags.includes(tag)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
wardrobeSchema.methods.generateOutfitRecommendations = function() {
|
||||||
|
// This would integrate with the recommendation service
|
||||||
|
const tops = this.getItemsByCategory('tops');
|
||||||
|
const bottoms = this.getItemsByCategory('bottoms');
|
||||||
|
const shoes = this.getItemsByCategory('shoes');
|
||||||
|
const accessories = this.getItemsByCategory('accessories');
|
||||||
|
|
||||||
|
const recommendations = [];
|
||||||
|
|
||||||
|
// Simple outfit combination logic
|
||||||
|
for (let i = 0; i < Math.min(3, tops.length); i++) {
|
||||||
|
for (let j = 0; j < Math.min(3, bottoms.length); j++) {
|
||||||
|
for (let k = 0; k < Math.min(2, shoes.length); k++) {
|
||||||
|
recommendations.push({
|
||||||
|
id: `${tops[i]._id}-${bottoms[j]._id}-${shoes[k]._id}`,
|
||||||
|
items: [tops[i], bottoms[j], shoes[k]],
|
||||||
|
confidence: Math.random(), // This would come from AI analysis
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return recommendations.slice(0, 10); // Limit to 10 recommendations
|
||||||
|
};
|
||||||
|
|
||||||
|
// Static methods
|
||||||
|
wardrobeSchema.statics.findByUserId = function(userId) {
|
||||||
|
return this.findOne({ userId });
|
||||||
|
};
|
||||||
|
|
||||||
|
wardrobeSchema.statics.findPublicWardrobes = function(limit = 20, skip = 0) {
|
||||||
|
return this.find({ isPublic: true })
|
||||||
|
.limit(limit)
|
||||||
|
.skip(skip)
|
||||||
|
.sort({ updatedAt: -1 });
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = mongoose.model('Wardrobe', wardrobeSchema);
|
||||||
328
src/routes/admin.js
Normal file
328
src/routes/admin.js
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
// const express = require('express');
|
||||||
|
// const multer = require('multer');
|
||||||
|
// const { prisma } = require('../config/database');
|
||||||
|
// const Product = require('../models/mongodb/Product');
|
||||||
|
// const { protect, authorize } = require('../middleware/auth');
|
||||||
|
|
||||||
|
// const dashboard = require('../controllers/admin/dashboardController');
|
||||||
|
// const users = require('../controllers/admin/userController');
|
||||||
|
// const orders = require('../controllers/admin/orderController');
|
||||||
|
// const products = require('../controllers/admin/productController');
|
||||||
|
// const categories = require('../controllers/admin/categoryController');
|
||||||
|
// const coupons = require('../controllers/admin/couponController');
|
||||||
|
|
||||||
|
// const router = express.Router();
|
||||||
|
|
||||||
|
// // ✅ FIXED: Use multer().any() to accept dynamic field names
|
||||||
|
// const upload = multer({
|
||||||
|
// storage: multer.memoryStorage(),
|
||||||
|
// limits: {
|
||||||
|
// fileSize: 5 * 1024 * 1024, // 5MB limit per file
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
|
||||||
|
// // All routes require admin authentication
|
||||||
|
// router.use(protect);
|
||||||
|
// router.use(authorize('ADMIN'));
|
||||||
|
|
||||||
|
// // @desc Get dashboard statistics
|
||||||
|
// // @route GET /api/admin/dashboard
|
||||||
|
// // @access Private/Admin
|
||||||
|
// router.get('/dashboard', dashboard.getDashboardStats);
|
||||||
|
|
||||||
|
// /**
|
||||||
|
// * @desc Get coupon statistics
|
||||||
|
// * @route GET /api/coupons/admin/stats
|
||||||
|
// * @access Private/Admin
|
||||||
|
// */
|
||||||
|
// router.get(
|
||||||
|
// '/stats',
|
||||||
|
// protect,
|
||||||
|
// authorize('ADMIN', 'SUPER_ADMIN'),
|
||||||
|
// coupons.getCouponStats
|
||||||
|
// );
|
||||||
|
|
||||||
|
// // @desc Get all users with pagination
|
||||||
|
// // @route GET /api/admin/users
|
||||||
|
// // @access Private/Admin
|
||||||
|
// router.get('/users', users.getAllUsers);
|
||||||
|
|
||||||
|
// router.get('/users/:id', users.getUserById);
|
||||||
|
// // @desc Update user status
|
||||||
|
// // @route PUT /api/admin/users/:id/status
|
||||||
|
// // @access Private/Admin
|
||||||
|
// router.put('/users/:id/status', users.updateUserStatus);
|
||||||
|
|
||||||
|
// // @desc Get all orders with filters
|
||||||
|
// // @route GET /api/admin/orders
|
||||||
|
// // @access Private/Admin
|
||||||
|
// router.get('/orders', orders.getAllOrders);
|
||||||
|
|
||||||
|
// /**
|
||||||
|
// * @desc Get status change statistics
|
||||||
|
// * @route GET /api/admin/orders/stats/status-changes
|
||||||
|
// * @access Private/Admin
|
||||||
|
// */
|
||||||
|
// router.get(
|
||||||
|
// '/stats/status-changes',
|
||||||
|
// protect,
|
||||||
|
// authorize('ADMIN', 'SUPER_ADMIN'),
|
||||||
|
// orders.getStatusChangeStats
|
||||||
|
// );
|
||||||
|
|
||||||
|
// /**
|
||||||
|
// * @desc Get single order with full history
|
||||||
|
// * @route GET /api/admin/orders/:orderId
|
||||||
|
// * @access Private/Admin
|
||||||
|
// */
|
||||||
|
// router.get(
|
||||||
|
// '/:orderId',
|
||||||
|
// protect,
|
||||||
|
// authorize('ADMIN', 'SUPER_ADMIN'),
|
||||||
|
// orders.getOrderById
|
||||||
|
// );
|
||||||
|
|
||||||
|
// /**
|
||||||
|
// * @desc Get order status history
|
||||||
|
// * @route GET /api/admin/orders/:orderId/history
|
||||||
|
// * @access Private/Admin
|
||||||
|
// */
|
||||||
|
// router.get(
|
||||||
|
// '/:orderId/history',
|
||||||
|
// protect,
|
||||||
|
// authorize('ADMIN', 'SUPER_ADMIN'),
|
||||||
|
// orders.getOrderStatusHistory
|
||||||
|
// );
|
||||||
|
|
||||||
|
// //Order Details Page
|
||||||
|
// router.get('/orders/:id', orders.getOrderDetails);
|
||||||
|
|
||||||
|
// // @desc Get all products
|
||||||
|
// // @route GET /api/admin/products
|
||||||
|
// // @access Private/Admin
|
||||||
|
// router.get('/products', products.getAllProducts);
|
||||||
|
|
||||||
|
// // @desc Create new product
|
||||||
|
// // @route POST /api/admin/products
|
||||||
|
// // @access Private/Admin
|
||||||
|
// // Create Product Route
|
||||||
|
// router.post(
|
||||||
|
// '/products',
|
||||||
|
// protect,
|
||||||
|
// authorize('ADMIN', 'SUPER_ADMIN'),
|
||||||
|
// upload.any(), // ✅ This accepts ANY field names (including dynamic variant fields)
|
||||||
|
// products.createProduct
|
||||||
|
// );
|
||||||
|
|
||||||
|
// /**
|
||||||
|
// * @desc Get all coupons
|
||||||
|
// * @route GET /api/coupons/admin
|
||||||
|
// * @access Private/Admin
|
||||||
|
// */
|
||||||
|
// router.get(
|
||||||
|
// '/coupons',
|
||||||
|
// protect,
|
||||||
|
// authorize('ADMIN', 'SUPER_ADMIN'),
|
||||||
|
// coupons.getAllCoupons
|
||||||
|
// );
|
||||||
|
|
||||||
|
// // @desc Update product
|
||||||
|
// // @route PUT /api/admin/products/:id
|
||||||
|
// // @access Private/Admin
|
||||||
|
// router.put('/products/:id', products.updateProduct);
|
||||||
|
|
||||||
|
// // @desc Delete product
|
||||||
|
// // @route DELETE /api/admin/products/:id
|
||||||
|
// // @access Private/Admin
|
||||||
|
// router.delete('/products/:id', products.deleteProduct);
|
||||||
|
|
||||||
|
// // @desc Get all categories
|
||||||
|
// // @route GET /api/admin/categories
|
||||||
|
// // @access Private/Admin
|
||||||
|
// router.get('/categories', categories.getAllCategories);
|
||||||
|
|
||||||
|
// // @desc Create new category
|
||||||
|
// // @route POST /api/admin/categories
|
||||||
|
// // @access Private/Admin
|
||||||
|
// router.post('/categories', categories.createCategory);
|
||||||
|
|
||||||
|
// router.put('/categories/:id', categories.updateCategory);
|
||||||
|
|
||||||
|
// router.delete('/categories/:id', categories.deleteCategory);
|
||||||
|
|
||||||
|
// router.patch('/categories/:id/status', categories.toggleCategoryStatus);
|
||||||
|
|
||||||
|
// router.patch('/categories/reorder', categories.reorderCategories);
|
||||||
|
|
||||||
|
// router.get('/categories/:id', categories.getCategoryById);
|
||||||
|
|
||||||
|
// // Category tree / hierarchy
|
||||||
|
// router.get('/tree', categories.getCategoryHierarchy);
|
||||||
|
|
||||||
|
// // @desc Get all coupons
|
||||||
|
// // @route GET /api/admin/coupons
|
||||||
|
// // @access Private/Admin
|
||||||
|
// // router.get('/coupons', coupons.getAllCoupons);
|
||||||
|
|
||||||
|
// // @desc Create new coupon
|
||||||
|
// // @route POST /api/admin/coupons
|
||||||
|
// // @access Private/Admin
|
||||||
|
// // router.post('/coupons', coupons.createCoupon);
|
||||||
|
|
||||||
|
// // ==========================================
|
||||||
|
// // ADMIN ROUTES
|
||||||
|
// // ==========================================
|
||||||
|
|
||||||
|
// /**
|
||||||
|
// * @desc Get single coupon
|
||||||
|
// * @route GET /api/coupons/admin/:id
|
||||||
|
// * @access Private/Admin
|
||||||
|
// */
|
||||||
|
// router.get(
|
||||||
|
// '/coupons/:id',
|
||||||
|
// protect,
|
||||||
|
// authorize('ADMIN', 'SUPER_ADMIN'),
|
||||||
|
// coupons.getCouponById
|
||||||
|
// );
|
||||||
|
|
||||||
|
// /**
|
||||||
|
// * @desc Create coupon
|
||||||
|
// * @route POST /api/coupons/admin
|
||||||
|
// * @access Private/Admin
|
||||||
|
// */
|
||||||
|
// router.post(
|
||||||
|
// '/coupons',
|
||||||
|
// protect,
|
||||||
|
// authorize('ADMIN', 'SUPER_ADMIN'),
|
||||||
|
// coupons.createCoupon
|
||||||
|
// );
|
||||||
|
|
||||||
|
// /**
|
||||||
|
// * @desc Update coupon
|
||||||
|
// * @route PUT /api/coupons/admin/:id
|
||||||
|
// * @access Private/Admin
|
||||||
|
// */
|
||||||
|
// router.put(
|
||||||
|
// '/coupons/:id',
|
||||||
|
// protect,
|
||||||
|
// authorize('ADMIN', 'SUPER_ADMIN'),
|
||||||
|
// coupons.updateCoupon
|
||||||
|
// );
|
||||||
|
|
||||||
|
// /**
|
||||||
|
// * @desc Delete coupon
|
||||||
|
// * @route DELETE /api/coupons/admin/:id
|
||||||
|
// * @access Private/Admin
|
||||||
|
// */
|
||||||
|
// router.delete(
|
||||||
|
// '/coupons/:id',
|
||||||
|
// protect,
|
||||||
|
// authorize('ADMIN', 'SUPER_ADMIN'),
|
||||||
|
// coupons.deleteCoupon
|
||||||
|
// );
|
||||||
|
|
||||||
|
// /**
|
||||||
|
// * @desc Toggle coupon status
|
||||||
|
// * @route PATCH /api/coupons/admin/:id/toggle
|
||||||
|
// * @access Private/Admin
|
||||||
|
// */
|
||||||
|
// router.patch(
|
||||||
|
// '/coupons/:id/toggle',
|
||||||
|
// protect,
|
||||||
|
// authorize('ADMIN', 'SUPER_ADMIN'),
|
||||||
|
// coupons.toggleCouponStatus
|
||||||
|
// );
|
||||||
|
|
||||||
|
// module.exports = router;
|
||||||
|
|
||||||
|
// routes/adminRoutes.js - FIXED VERSION with correct route ordering
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const multer = require('multer');
|
||||||
|
const { prisma } = require('../config/database');
|
||||||
|
const Product = require('../models/mongodb/Product');
|
||||||
|
const { protect, authorize } = require('../middleware/auth');
|
||||||
|
|
||||||
|
const dashboard = require('../controllers/admin/dashboardController');
|
||||||
|
const users = require('../controllers/admin/userController');
|
||||||
|
const orders = require('../controllers/admin/orderController');
|
||||||
|
const products = require('../controllers/admin/productController');
|
||||||
|
const categories = require('../controllers/admin/categoryController');
|
||||||
|
const coupons = require('../controllers/admin/couponController');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
const upload = multer({
|
||||||
|
storage: multer.memoryStorage(),
|
||||||
|
limits: {
|
||||||
|
fileSize: 5 * 1024 * 1024,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// All routes require admin authentication
|
||||||
|
router.use(protect);
|
||||||
|
router.use(authorize('ADMIN'));
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// DASHBOARD
|
||||||
|
// ==========================================
|
||||||
|
router.get('/dashboard', dashboard.getDashboardStats);
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// USERS
|
||||||
|
// ==========================================
|
||||||
|
router.get('/users', users.getAllUsers);
|
||||||
|
router.get('/users/:id', users.getUserById);
|
||||||
|
router.put('/users/:id/status', users.updateUserStatus);
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// ORDERS - SPECIFIC ROUTES FIRST
|
||||||
|
// ==========================================
|
||||||
|
router.get('/orders/stats/status-changes', orders.getStatusChangeStats);
|
||||||
|
router.get('/orders', orders.getAllOrders);
|
||||||
|
router.get('/orders/:id', orders.getOrderDetails);
|
||||||
|
router.get('/orders/:orderId/history', orders.getOrderStatusHistory);
|
||||||
|
// router.put('/orders/:orderId/status', orders.updateOrderStatus);
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// PRODUCTS
|
||||||
|
// ==========================================
|
||||||
|
router.get('/products', products.getAllProducts);
|
||||||
|
router.post('/products', upload.any(), products.createProduct);
|
||||||
|
router.put('/products/:id', products.updateProduct);
|
||||||
|
router.delete('/products/:id', products.deleteProduct);
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// CATEGORIES - SPECIFIC ROUTES FIRST
|
||||||
|
// ==========================================
|
||||||
|
router.get('/tree', categories.getCategoryHierarchy);
|
||||||
|
router.patch('/categories/reorder', categories.reorderCategories);
|
||||||
|
router.get('/categories', categories.getAllCategories);
|
||||||
|
// router.post('/categories', categories.createCategory);
|
||||||
|
router.post(
|
||||||
|
'/categories',
|
||||||
|
upload.single('image'), // 👈 THIS IS REQUIRED
|
||||||
|
categories.createCategory
|
||||||
|
);
|
||||||
|
router.get('/categories/:id', categories.getCategoryById);
|
||||||
|
// router.put('/categories/:id', categories.updateCategory);
|
||||||
|
router.put(
|
||||||
|
'/categories/:id',
|
||||||
|
upload.single('image'), // 👈 important
|
||||||
|
categories.updateCategory
|
||||||
|
);
|
||||||
|
router.delete('/categories/:id', categories.deleteCategory);
|
||||||
|
router.patch('/categories/:id/status', categories.toggleCategoryStatus);
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// COUPONS - SPECIFIC ROUTES FIRST
|
||||||
|
// ==========================================
|
||||||
|
router.get('/stats', coupons.getCouponStats);
|
||||||
|
router.get('/coupons', coupons.getAllCoupons);
|
||||||
|
router.post('/coupons', coupons.createCoupon);
|
||||||
|
router.get('/coupons/:id', coupons.getCouponById);
|
||||||
|
router.put('/coupons/:id', coupons.updateCoupon);
|
||||||
|
router.delete('/coupons/:id', coupons.deleteCoupon);
|
||||||
|
router.patch('/coupons/:id/toggle', coupons.toggleCouponStatus);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
68
src/routes/auth.js
Normal file
68
src/routes/auth.js
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const authService = require('../services/authService');
|
||||||
|
const { protect } = require('../middleware/auth');
|
||||||
|
const authController = require('../controllers/authController');
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// @desc Register user
|
||||||
|
// @route POST /api/auth/register
|
||||||
|
// @access Public
|
||||||
|
router.post('/register', authController.register);
|
||||||
|
|
||||||
|
// @desc Login user
|
||||||
|
// @route POST /api/auth/login
|
||||||
|
// @access Public
|
||||||
|
router.post('/login', authController.login);
|
||||||
|
|
||||||
|
|
||||||
|
// @desc Send OTP to WhatsApp
|
||||||
|
// @route POST /api/auth/send-otp
|
||||||
|
// @access Public
|
||||||
|
router.post('/send-otp', authController.sendLoginOTP);
|
||||||
|
|
||||||
|
// @desc Verify OTP and login
|
||||||
|
// @route POST /api/auth/verify-otp
|
||||||
|
// @access Public
|
||||||
|
router.post('/verify-otp', authController.verifyLoginOTP);
|
||||||
|
|
||||||
|
// @desc Refresh token
|
||||||
|
// @route POST /api/auth/refresh
|
||||||
|
// @access Public
|
||||||
|
router.post('/refresh', authController.refreshToken);
|
||||||
|
|
||||||
|
// @desc Logout user
|
||||||
|
// @route POST /api/auth/logout
|
||||||
|
// @access Private
|
||||||
|
router.post('/logout', protect, authController.logout);
|
||||||
|
|
||||||
|
// @desc Change password
|
||||||
|
// @route PUT /api/auth/change-password
|
||||||
|
// @access Private
|
||||||
|
router.put('/change-password', protect, authController.changePassword);
|
||||||
|
|
||||||
|
// @desc Request password reset
|
||||||
|
// @route POST /api/auth/forgot-password
|
||||||
|
// @access Public
|
||||||
|
router.post('/forgot-password', authController.forgotPassword);
|
||||||
|
|
||||||
|
// @desc Reset password with token
|
||||||
|
// @route POST /api/auth/reset-password
|
||||||
|
// @access Public
|
||||||
|
router.post('/reset-password', authController.resetPassword);
|
||||||
|
|
||||||
|
// @desc Send verification email
|
||||||
|
// @route POST /api/auth/send-verification
|
||||||
|
// @access Private
|
||||||
|
router.post('/send-verification', protect, authController.sendVerification);
|
||||||
|
|
||||||
|
// @desc Verify email with token
|
||||||
|
// @route POST /api/auth/verify-email
|
||||||
|
// @access Public
|
||||||
|
router.post('/verify-email', authController.verifyEmail);
|
||||||
|
|
||||||
|
// @desc Get current user profile
|
||||||
|
// @route GET /api/auth/me
|
||||||
|
// @access Private
|
||||||
|
router.get('/me', protect, authController.getMe);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
40
src/routes/couponRoutes.js
Normal file
40
src/routes/couponRoutes.js
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const { protect, authorize, optionalAuth } = require('../middleware/auth');
|
||||||
|
// const adminCouponController = require('../controllers/admin/couponController');
|
||||||
|
const userCouponController = require('../controllers/couponController');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// USER ROUTES (Public/Customer)
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @desc Get available coupons
|
||||||
|
* @route GET /api/coupons/available
|
||||||
|
* @access Public
|
||||||
|
*/
|
||||||
|
router.get('/available', userCouponController.getAvailableCoupons);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @desc Validate coupon code
|
||||||
|
* @route POST /api/coupons/validate
|
||||||
|
* @access Public
|
||||||
|
*/
|
||||||
|
router.post('/validate', optionalAuth, userCouponController.validateCoupon);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @desc Apply coupon to order
|
||||||
|
* @route POST /api/coupons/apply
|
||||||
|
* @access Private
|
||||||
|
*/
|
||||||
|
router.post('/apply', protect, userCouponController.applyCouponToOrder);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @desc Remove coupon from order
|
||||||
|
* @route POST /api/coupons/remove
|
||||||
|
* @access Private
|
||||||
|
*/
|
||||||
|
router.post('/remove', protect, userCouponController.removeCouponFromOrder);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
35
src/routes/deliveryRoutes.js
Normal file
35
src/routes/deliveryRoutes.js
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
// routes/deliveryRoutes.js
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const { protect, authorize } = require('../middleware/auth');
|
||||||
|
const trackingController = require('../controllers/orderTrackingController');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @desc Get delivery estimation for pincode
|
||||||
|
* @route POST /api/delivery/estimate
|
||||||
|
* @access Public
|
||||||
|
*/
|
||||||
|
router.post('/estimate', trackingController.getDeliveryEstimate);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @desc Get order tracking details
|
||||||
|
* @route GET /api/orders/:orderId/tracking
|
||||||
|
* @access Private
|
||||||
|
*/
|
||||||
|
router.get('/orders/:orderId/tracking', protect, trackingController.getOrderTracking);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @desc Update order status (Admin)
|
||||||
|
* @route PUT /api/admin/orders/:orderId/status
|
||||||
|
* @access Private/Admin
|
||||||
|
*/
|
||||||
|
router.put(
|
||||||
|
'/admin/:orderId/status',
|
||||||
|
protect,
|
||||||
|
authorize('ADMIN', 'SUPER_ADMIN'),
|
||||||
|
trackingController.updateOrderStatus
|
||||||
|
);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
100
src/routes/orders.js
Normal file
100
src/routes/orders.js
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const { prisma } = require('../config/database');
|
||||||
|
const { protect, authorize } = require('../middleware/auth');
|
||||||
|
const orderController = require('../controllers/orderController');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// @desc Create new order
|
||||||
|
// @route POST /api/orders
|
||||||
|
// @access Private
|
||||||
|
router.post('/', protect, orderController.createOrder);
|
||||||
|
|
||||||
|
// @desc Get user orders
|
||||||
|
// @route GET /api/orders
|
||||||
|
// @access Private
|
||||||
|
router.get('/', protect, orderController.getUserOrders);
|
||||||
|
|
||||||
|
// @desc Get single order
|
||||||
|
// @route GET /api/orders/:id
|
||||||
|
// @access Private
|
||||||
|
router.get('/:id', protect, orderController.getOrderById);
|
||||||
|
|
||||||
|
// @desc Update order status (Admin only)
|
||||||
|
// @route PUT /api/orders/:id/status
|
||||||
|
// @access Private/Admin
|
||||||
|
router.put(
|
||||||
|
'/:id/status',
|
||||||
|
protect,
|
||||||
|
authorize('ADMIN'),
|
||||||
|
orderController.updateOrderStatus
|
||||||
|
);
|
||||||
|
|
||||||
|
// @desc Cancel order
|
||||||
|
// @route PUT /api/orders/:id/cancel
|
||||||
|
// @access Private
|
||||||
|
router.put('/:id/cancel', protect, orderController.cancelOrder);
|
||||||
|
|
||||||
|
// @desc Return order
|
||||||
|
// @route PUT /api/orders/:id/return
|
||||||
|
// @access Private
|
||||||
|
router.put('/:id/return', protect, orderController.returnOrder);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// @desc Get all orders (Admin only)
|
||||||
|
// @route GET /api/orders/admin/all
|
||||||
|
// @access Private/Admin
|
||||||
|
router.get(
|
||||||
|
'/admin/all',
|
||||||
|
protect,
|
||||||
|
authorize('ADMIN'),
|
||||||
|
orderController.getAllOrdersAdmin
|
||||||
|
);
|
||||||
|
|
||||||
|
// Admin approve/reject return
|
||||||
|
router.put(
|
||||||
|
'/:id/return/status',
|
||||||
|
protect,
|
||||||
|
authorize('ADMIN'),
|
||||||
|
orderController.updateReturnStatus
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
// Admin: list all return requests
|
||||||
|
// router.get(
|
||||||
|
// '/admin/returns',
|
||||||
|
// protect,
|
||||||
|
// authorize('ADMIN'),
|
||||||
|
// orderController.getReturnRequestsAdmin
|
||||||
|
// );
|
||||||
|
|
||||||
|
// Admin: list all return requests
|
||||||
|
router.get(
|
||||||
|
'/admin/returns',
|
||||||
|
protect,
|
||||||
|
authorize('ADMIN'),
|
||||||
|
orderController.getAdminReturnRequests
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Admin: list all returned products (approved/completed)
|
||||||
|
router.get(
|
||||||
|
'/admin/returns/list',
|
||||||
|
protect,
|
||||||
|
authorize('ADMIN'),
|
||||||
|
orderController.getReturnedProducts
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
// Get single return request details
|
||||||
|
router.get(
|
||||||
|
'/admin/returns/:id',
|
||||||
|
protect,
|
||||||
|
authorize('ADMIN'),
|
||||||
|
orderController.getReturnRequestById
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
52
src/routes/paymentRoutes.js
Normal file
52
src/routes/paymentRoutes.js
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
// routes/paymentRoutes.js
|
||||||
|
const express = require('express');
|
||||||
|
const paytmController = require('../controllers/payment/paytmController');
|
||||||
|
const { protect, authorize } = require('../middleware/auth');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// ======================
|
||||||
|
// PAYTM PAYMENT ROUTES
|
||||||
|
// ======================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @desc Initiate Paytm Payment
|
||||||
|
* @route POST /api/payments/paytm/initiate
|
||||||
|
* @access Private
|
||||||
|
*/
|
||||||
|
router.post('/paytm/initiate', protect, paytmController.initiatePayment);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @desc Paytm Payment Callback (Called by Paytm after payment)
|
||||||
|
* @route POST /api/payments/paytm/callback
|
||||||
|
* @access Public (No auth - Paytm calls this)
|
||||||
|
*/
|
||||||
|
router.post('/paytm/callback', paytmController.paymentCallback);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @desc Check Payment Status
|
||||||
|
* @route GET /api/payments/paytm/status/:orderId
|
||||||
|
* @access Private
|
||||||
|
*/
|
||||||
|
router.get('/paytm/status/:orderId', protect, paytmController.checkPaymentStatus);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @desc Get Payment Details
|
||||||
|
* @route GET /api/payments/paytm/:orderId
|
||||||
|
* @access Private
|
||||||
|
*/
|
||||||
|
router.get('/paytm/:orderId', protect, paytmController.getPaymentDetails);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @desc Process Refund (Admin only)
|
||||||
|
* @route POST /api/payments/paytm/refund
|
||||||
|
* @access Private/Admin
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
'/paytm/refund',
|
||||||
|
protect,
|
||||||
|
authorize('ADMIN', 'SUPER_ADMIN'),
|
||||||
|
paytmController.processRefund
|
||||||
|
);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
137
src/routes/products.js
Normal file
137
src/routes/products.js
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const Product = require('../models/mongodb/Product');
|
||||||
|
const { protect, authorize, optionalAuth } = require('../middleware/auth');
|
||||||
|
const productController = require('../controllers/products/productController');
|
||||||
|
const recommendationController = require('../controllers/products/recommendation');
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @desc Get personalized recommendations for user
|
||||||
|
* @route GET /api/products/recommendations/personalized
|
||||||
|
* @access Private/Public
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
'/recommendations/personalized',
|
||||||
|
optionalAuth,
|
||||||
|
recommendationController.getPersonalizedRecommendations
|
||||||
|
);
|
||||||
|
|
||||||
|
// SPECIFIC ROUTES FIRST (before /:slug)
|
||||||
|
// @desc Get products by category
|
||||||
|
// @route GET /api/products/category/:category
|
||||||
|
// @access Public
|
||||||
|
router.get('/categories', productController.getAllCategories);
|
||||||
|
|
||||||
|
router.get('/debug-categories', productController.debugMissingCategories);
|
||||||
|
|
||||||
|
router.get('/tree', productController.getUserCategoryHierarchy);
|
||||||
|
|
||||||
|
// @desc Get available colors/variants for a category
|
||||||
|
// @route GET /api/products/category-colors/:categorySlug
|
||||||
|
// @access Public
|
||||||
|
router.get(
|
||||||
|
'/category-colors/:categorySlug',
|
||||||
|
optionalAuth,
|
||||||
|
productController.getCategoryColors
|
||||||
|
);
|
||||||
|
|
||||||
|
// @desc Get featured products
|
||||||
|
// @route GET /api/products/featured
|
||||||
|
// @access Public
|
||||||
|
router.get('/featured', optionalAuth, productController.getFeaturedProducts);
|
||||||
|
|
||||||
|
// @desc Search products
|
||||||
|
// @route GET /api/products/search/:query
|
||||||
|
// @access Public
|
||||||
|
router.get('/search/:query', optionalAuth, productController.searchProducts);
|
||||||
|
|
||||||
|
router.get('/most-loved', productController.getMostLovedProducts);
|
||||||
|
|
||||||
|
router.get('/new-arrivals', productController.getNewArrivals);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/category/:categorySlug',
|
||||||
|
optionalAuth,
|
||||||
|
productController.getProductsByCategory
|
||||||
|
);
|
||||||
|
|
||||||
|
// @desc Get all products
|
||||||
|
// @route GET /api/products
|
||||||
|
// @access Public
|
||||||
|
router.get('/', optionalAuth, productController.getAllProducts);
|
||||||
|
|
||||||
|
|
||||||
|
// ======================
|
||||||
|
// PRODUCT-SPECIFIC RECOMMENDATION ROUTES (BEFORE /:slug)
|
||||||
|
// ======================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @desc Get recommended products for a specific product
|
||||||
|
* @route GET /api/products/:slug/recommendations
|
||||||
|
* @access Public
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
'/:slug/recommendations',
|
||||||
|
optionalAuth,
|
||||||
|
recommendationController.getProductRecommendations
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @desc Get "Customers also bought" products
|
||||||
|
* @route GET /api/products/:slug/also-bought
|
||||||
|
* @access Public
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
'/:slug/also-bought',
|
||||||
|
optionalAuth,
|
||||||
|
recommendationController.getAlsoBoughtProducts
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @desc Get similar products
|
||||||
|
* @route GET /api/products/:slug/similar
|
||||||
|
* @access Public
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
'/:slug/similar',
|
||||||
|
optionalAuth,
|
||||||
|
recommendationController.getSimilarProducts
|
||||||
|
);
|
||||||
|
|
||||||
|
// @desc Get single product
|
||||||
|
// @route GET /api/products/:slug
|
||||||
|
// @access Public
|
||||||
|
router.get('/:slug', optionalAuth, productController.getProductBySlug);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// ADMIN ROUTES
|
||||||
|
// @desc Create new product
|
||||||
|
// @route POST /api/products
|
||||||
|
// @access Private/Admin
|
||||||
|
router.post('/', protect, authorize('ADMIN'), productController.createProduct);
|
||||||
|
|
||||||
|
// @desc Update product
|
||||||
|
// @route PUT /api/products/:id
|
||||||
|
// @access Private/Admin
|
||||||
|
router.put(
|
||||||
|
'/:id',
|
||||||
|
protect,
|
||||||
|
authorize('ADMIN'),
|
||||||
|
productController.updateProduct
|
||||||
|
);
|
||||||
|
|
||||||
|
// @desc Delete product
|
||||||
|
// @route DELETE /api/products/:id
|
||||||
|
// @access Private/Admin
|
||||||
|
router.delete(
|
||||||
|
'/:id',
|
||||||
|
protect,
|
||||||
|
authorize('ADMIN'),
|
||||||
|
productController.deleteProduct
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
85
src/routes/reports.js
Normal file
85
src/routes/reports.js
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
// const express = require("express");
|
||||||
|
// const { protect, authorize } = require("../middleware/auth");
|
||||||
|
|
||||||
|
// const reports = require("../controllers/admin/reportController");
|
||||||
|
|
||||||
|
// const router = express.Router();
|
||||||
|
|
||||||
|
// router.use(protect);
|
||||||
|
// router.use(authorize("ADMIN"));
|
||||||
|
|
||||||
|
// // Reports Endpoints
|
||||||
|
// router.get("/overview", reports.getOverviewReport);
|
||||||
|
// router.get("/sales", reports.getSalesAnalytics);
|
||||||
|
// router.get("/customers", reports.getCustomerStats);
|
||||||
|
// router.get("/sellers", reports.getSellerStats);
|
||||||
|
// router.get("/orders", reports.getOrderAnalytics);
|
||||||
|
|
||||||
|
// router.get("/inventory", reports.getInventoryStats);
|
||||||
|
// router.get("/financial", reports.getFinancialStats);
|
||||||
|
|
||||||
|
|
||||||
|
// module.exports = router;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const { protect, authorize } = require('../middleware/auth');
|
||||||
|
const reports = require('../controllers/admin/reportController');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// ─── Auth Guard ───────────────────────────────
|
||||||
|
router.use(protect);
|
||||||
|
router.use(authorize('ADMIN'));
|
||||||
|
|
||||||
|
// ─── Overview ────────────────────────────────
|
||||||
|
// GET /api/admin/reports/overview?from=2024-01-01&to=2024-12-31
|
||||||
|
router.get('/overview', reports.getOverviewReport);
|
||||||
|
|
||||||
|
// ─── Sales ───────────────────────────────────
|
||||||
|
// GET /api/admin/reports/sales?from=&to=&groupBy=daily|monthly
|
||||||
|
router.get('/sales', reports.getSalesAnalytics);
|
||||||
|
|
||||||
|
// ─── Customers ───────────────────────────────
|
||||||
|
// GET /api/admin/reports/customers?view=weekly|monthly|yearly
|
||||||
|
router.get('/customers', reports.getCustomerStats);
|
||||||
|
|
||||||
|
// ─── Sellers ─────────────────────────────────
|
||||||
|
// GET /api/admin/reports/sellers?page=1&limit=20
|
||||||
|
router.get('/sellers', reports.getSellerStats);
|
||||||
|
|
||||||
|
// ─── Orders ──────────────────────────────────
|
||||||
|
// GET /api/admin/reports/orders?from=&to=
|
||||||
|
router.get('/orders', reports.getOrderAnalytics);
|
||||||
|
|
||||||
|
// ─── Inventory ───────────────────────────────
|
||||||
|
// GET /api/admin/reports/inventory?page=1&limit=20
|
||||||
|
router.get('/inventory', reports.getInventoryStats);
|
||||||
|
|
||||||
|
// ─── Financial ───────────────────────────────
|
||||||
|
// GET /api/admin/reports/financial?from=&to=
|
||||||
|
router.get('/financial', reports.getFinancialStats);
|
||||||
|
|
||||||
|
// ─── Payouts ─────────────────────────────────
|
||||||
|
// GET /api/admin/reports/payouts?page=1&limit=20&from=&to=
|
||||||
|
router.get('/payouts', reports.getPayoutHistory);
|
||||||
|
|
||||||
|
// ─── Activity Feed ────────────────────────────
|
||||||
|
// GET /api/admin/reports/activity?limit=20
|
||||||
|
// Requires ActivityLog model in Prisma schema (see note in controller)
|
||||||
|
router.get('/activity', reports.getActivityFeed);
|
||||||
|
|
||||||
|
// ─── Top Products ────────────────────────────
|
||||||
|
// GET /api/admin/reports/top-products?limit=10
|
||||||
|
router.get('/top-products', reports.getTopProducts);
|
||||||
|
|
||||||
|
// ─── Coupon Stats ────────────────────────────
|
||||||
|
// GET /api/admin/reports/coupons?from=&to=
|
||||||
|
router.get('/coupons', reports.getCouponStats);
|
||||||
|
|
||||||
|
// ─── Returns (uncomment when ReturnModel is ready) ───
|
||||||
|
// GET /api/admin/reports/returns?from=&to=
|
||||||
|
// router.get('/returns', reports.getReturnAnalytics);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
17
src/routes/upload.routes.js
Normal file
17
src/routes/upload.routes.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
const express = require("express");
|
||||||
|
const multer = require("multer");
|
||||||
|
const { uploadToS3 } = require("../services/s3Upload.service.js");
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
const upload = multer({ dest: "uploads/" });
|
||||||
|
|
||||||
|
router.post("/upload", upload.single("image"), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const imageUrl = await uploadToS3(req.file);
|
||||||
|
res.json({ success: true, imageUrl });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
550
src/routes/users.js
Normal file
550
src/routes/users.js
Normal file
@@ -0,0 +1,550 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const { prisma } = require('../config/database');
|
||||||
|
const { protect, authorize, checkOwnership } = require('../middleware/auth');
|
||||||
|
|
||||||
|
const profile = require('../controllers/users/profileController');
|
||||||
|
const address = require('../controllers/users/addressController');
|
||||||
|
// const orders = require('../controllers/users/orderController');
|
||||||
|
const orders = require('../controllers/users/orderController');
|
||||||
|
const wishlist = require('../controllers/users/wishlistController');
|
||||||
|
const adminUsers = require('../controllers/users/adminUserController');
|
||||||
|
const cart = require('../controllers/users/cartController');
|
||||||
|
// import { uploadProfile } from "../middleware/uploadProfile";
|
||||||
|
const { uploadProfile } = require('../middleware/uploadProfile');
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// @desc Get user profile
|
||||||
|
// @route GET /api/users/profile
|
||||||
|
// @access Private
|
||||||
|
// router.get('/profile', protect, async (req, res, next) => {
|
||||||
|
// try {
|
||||||
|
// const user = await prisma.user.findUnique({
|
||||||
|
// where: { id: req.user.id },
|
||||||
|
// select: {
|
||||||
|
// id: true,
|
||||||
|
// email: true,
|
||||||
|
// username: true,
|
||||||
|
// firstName: true,
|
||||||
|
// lastName: true,
|
||||||
|
// phone: true,
|
||||||
|
// avatar: true,
|
||||||
|
// role: true,
|
||||||
|
// isVerified: true,
|
||||||
|
// createdAt: true,
|
||||||
|
// lastLoginAt: true,
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
|
||||||
|
// res.json({
|
||||||
|
// success: true,
|
||||||
|
// data: { user },
|
||||||
|
// });
|
||||||
|
// } catch (error) {
|
||||||
|
// next(error);
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
|
||||||
|
router.get('/profile', protect, profile.getProfile);
|
||||||
|
|
||||||
|
// @desc Update user profile
|
||||||
|
// @route PUT /api/users/profile
|
||||||
|
// @access Private
|
||||||
|
// router.put('/profile', protect, async (req, res, next) => {
|
||||||
|
// try {
|
||||||
|
// const { firstName, lastName, username, phone } = req.body;
|
||||||
|
|
||||||
|
// // Check if username is already taken
|
||||||
|
// if (username) {
|
||||||
|
// const existingUser = await prisma.user.findFirst({
|
||||||
|
// where: {
|
||||||
|
// username,
|
||||||
|
// NOT: { id: req.user.id },
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
|
||||||
|
// if (existingUser) {
|
||||||
|
// return res.status(400).json({
|
||||||
|
// success: false,
|
||||||
|
// message: 'Username already taken',
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// const updatedUser = await prisma.user.update({
|
||||||
|
// where: { id: req.user.id },
|
||||||
|
// data: {
|
||||||
|
// firstName,
|
||||||
|
// lastName,
|
||||||
|
// username,
|
||||||
|
// phone,
|
||||||
|
// },
|
||||||
|
// select: {
|
||||||
|
// id: true,
|
||||||
|
// email: true,
|
||||||
|
// username: true,
|
||||||
|
// firstName: true,
|
||||||
|
// lastName: true,
|
||||||
|
// phone: true,
|
||||||
|
// avatar: true,
|
||||||
|
// role: true,
|
||||||
|
// isVerified: true,
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
|
||||||
|
// res.json({
|
||||||
|
// success: true,
|
||||||
|
// message: 'Profile updated successfully',
|
||||||
|
// data: { user: updatedUser },
|
||||||
|
// });
|
||||||
|
// } catch (error) {
|
||||||
|
// next(error);
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
|
||||||
|
router.put('/profile', protect, profile.updateProfile);
|
||||||
|
|
||||||
|
// @desc Upload user avatar
|
||||||
|
// @route POST /api/users/avatar
|
||||||
|
// @access Private
|
||||||
|
// router.post('/avatar', protect, async (req, res, next) => {
|
||||||
|
// try {
|
||||||
|
// // This would integrate with file upload middleware
|
||||||
|
// // For now, just return a placeholder
|
||||||
|
// res.json({
|
||||||
|
// success: true,
|
||||||
|
// message: 'Avatar upload endpoint - to be implemented with file upload middleware',
|
||||||
|
// });
|
||||||
|
// } catch (error) {
|
||||||
|
// next(error);
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
|
||||||
|
// router.post('/avatar', protect, profile.uploadAvatar);
|
||||||
|
router.post("/avatar", protect, uploadProfile.single("avatar"), profile.uploadAvatar);
|
||||||
|
|
||||||
|
|
||||||
|
// @desc Get user addresses
|
||||||
|
// @route GET /api/users/addresses
|
||||||
|
// @access Private
|
||||||
|
// router.get('/addresses', protect, async (req, res, next) => {
|
||||||
|
// try {
|
||||||
|
// const addresses = await prisma.address.findMany({
|
||||||
|
// where: { userId: req.user.id },
|
||||||
|
// orderBy: { createdAt: 'desc' },
|
||||||
|
// });
|
||||||
|
|
||||||
|
// res.json({
|
||||||
|
// success: true,
|
||||||
|
// data: { addresses },
|
||||||
|
// });
|
||||||
|
// } catch (error) {
|
||||||
|
// next(error);
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
|
||||||
|
router.get('/addresses', protect, address.getAddresses);
|
||||||
|
|
||||||
|
// @desc Add user address
|
||||||
|
// @route POST /api/users/addresses
|
||||||
|
// @access Private
|
||||||
|
// router.post('/addresses', protect, async (req, res, next) => {
|
||||||
|
// try {
|
||||||
|
// const {
|
||||||
|
// type,
|
||||||
|
// isDefault,
|
||||||
|
// firstName,
|
||||||
|
// lastName,
|
||||||
|
// company,
|
||||||
|
// addressLine1,
|
||||||
|
// addressLine2,
|
||||||
|
// city,
|
||||||
|
// state,
|
||||||
|
// postalCode,
|
||||||
|
// country,
|
||||||
|
// phone,
|
||||||
|
// } = req.body;
|
||||||
|
|
||||||
|
// // If this is set as default, unset other default addresses
|
||||||
|
// if (isDefault) {
|
||||||
|
// await prisma.address.updateMany({
|
||||||
|
// where: { userId: req.user.id },
|
||||||
|
// data: { isDefault: false },
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
// const address = await prisma.address.create({
|
||||||
|
// data: {
|
||||||
|
// userId: req.user.id,
|
||||||
|
// type,
|
||||||
|
// isDefault,
|
||||||
|
// firstName,
|
||||||
|
// lastName,
|
||||||
|
// company,
|
||||||
|
// addressLine1,
|
||||||
|
// addressLine2,
|
||||||
|
// city,
|
||||||
|
// state,
|
||||||
|
// postalCode,
|
||||||
|
// country,
|
||||||
|
// phone,
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
|
||||||
|
// res.status(201).json({
|
||||||
|
// success: true,
|
||||||
|
// message: 'Address added successfully',
|
||||||
|
// data: { address },
|
||||||
|
// });
|
||||||
|
// } catch (error) {
|
||||||
|
// next(error);
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
|
||||||
|
router.post('/addresses', protect, address.addAddress);
|
||||||
|
|
||||||
|
// @desc Update user address
|
||||||
|
// @route PUT /api/users/addresses/:id
|
||||||
|
// @access Private
|
||||||
|
// router.put('/addresses/:id', protect, async (req, res, next) => {
|
||||||
|
// try {
|
||||||
|
// const addressId = req.params.id;
|
||||||
|
// const {
|
||||||
|
// type,
|
||||||
|
// isDefault,
|
||||||
|
// firstName,
|
||||||
|
// lastName,
|
||||||
|
// company,
|
||||||
|
// addressLine1,
|
||||||
|
// addressLine2,
|
||||||
|
// city,
|
||||||
|
// state,
|
||||||
|
// postalCode,
|
||||||
|
// country,
|
||||||
|
// phone,
|
||||||
|
// } = req.body;
|
||||||
|
|
||||||
|
// // Check if address belongs to user
|
||||||
|
// const existingAddress = await prisma.address.findFirst({
|
||||||
|
// where: {
|
||||||
|
// id: addressId,
|
||||||
|
// userId: req.user.id,
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
|
||||||
|
// if (!existingAddress) {
|
||||||
|
// return res.status(404).json({
|
||||||
|
// success: false,
|
||||||
|
// message: 'Address not found',
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // If this is set as default, unset other default addresses
|
||||||
|
// if (isDefault) {
|
||||||
|
// await prisma.address.updateMany({
|
||||||
|
// where: { userId: req.user.id },
|
||||||
|
// data: { isDefault: false },
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
// const updatedAddress = await prisma.address.update({
|
||||||
|
// where: { id: addressId },
|
||||||
|
// data: {
|
||||||
|
// type,
|
||||||
|
// isDefault,
|
||||||
|
// firstName,
|
||||||
|
// lastName,
|
||||||
|
// company,
|
||||||
|
// addressLine1,
|
||||||
|
// addressLine2,
|
||||||
|
// city,
|
||||||
|
// state,
|
||||||
|
// postalCode,
|
||||||
|
// country,
|
||||||
|
// phone,
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
|
||||||
|
// res.json({
|
||||||
|
// success: true,
|
||||||
|
// message: 'Address updated successfully',
|
||||||
|
// data: { address: updatedAddress },
|
||||||
|
// });
|
||||||
|
// } catch (error) {
|
||||||
|
// next(error);
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
|
||||||
|
router.put('/addresses/:id', protect, address.updateAddress);
|
||||||
|
|
||||||
|
// @desc Delete user address
|
||||||
|
// @route DELETE /api/users/addresses/:id
|
||||||
|
// @access Private
|
||||||
|
// router.delete('/addresses/:id', protect, async (req, res, next) => {
|
||||||
|
// try {
|
||||||
|
// const addressId = req.params.id;
|
||||||
|
|
||||||
|
// // Check if address belongs to user
|
||||||
|
// const existingAddress = await prisma.address.findFirst({
|
||||||
|
// where: {
|
||||||
|
// id: addressId,
|
||||||
|
// userId: req.user.id,
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
|
||||||
|
// if (!existingAddress) {
|
||||||
|
// return res.status(404).json({
|
||||||
|
// success: false,
|
||||||
|
// message: 'Address not found',
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
// await prisma.address.delete({
|
||||||
|
// where: { id: addressId },
|
||||||
|
// });
|
||||||
|
|
||||||
|
// res.json({
|
||||||
|
// success: true,
|
||||||
|
// message: 'Address deleted successfully',
|
||||||
|
// });
|
||||||
|
// } catch (error) {
|
||||||
|
// next(error);
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
|
||||||
|
router.delete('/addresses/:id', protect, address.deleteAddress);
|
||||||
|
|
||||||
|
// @desc Get user orders
|
||||||
|
// @route GET /api/users/orders
|
||||||
|
// @access Private
|
||||||
|
// router.get('/orders', protect, async (req, res, next) => {
|
||||||
|
// try {
|
||||||
|
// const { page = 1, limit = 10, status } = req.query;
|
||||||
|
// const skip = (page - 1) * limit;
|
||||||
|
|
||||||
|
// const where = { userId: req.user.id };
|
||||||
|
// if (status) {
|
||||||
|
// where.status = status;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// const [orders, total] = await Promise.all([
|
||||||
|
// prisma.order.findMany({
|
||||||
|
// where,
|
||||||
|
// include: {
|
||||||
|
// items: true,
|
||||||
|
// address: true,
|
||||||
|
// },
|
||||||
|
// orderBy: { createdAt: 'desc' },
|
||||||
|
// skip: parseInt(skip),
|
||||||
|
// take: parseInt(limit),
|
||||||
|
// }),
|
||||||
|
// prisma.order.count({ where }),
|
||||||
|
// ]);
|
||||||
|
|
||||||
|
// res.json({
|
||||||
|
// success: true,
|
||||||
|
// data: {
|
||||||
|
// orders,
|
||||||
|
// pagination: {
|
||||||
|
// page: parseInt(page),
|
||||||
|
// limit: parseInt(limit),
|
||||||
|
// total,
|
||||||
|
// pages: Math.ceil(total / limit),
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
// } catch (error) {
|
||||||
|
// next(error);
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
|
||||||
|
router.get('/orders', protect, orders.getOrders);
|
||||||
|
|
||||||
|
// @desc Get user wishlist
|
||||||
|
// @route GET /api/users/wishlist
|
||||||
|
// @access Private
|
||||||
|
// router.get('/wishlist', protect, async (req, res, next) => {
|
||||||
|
// try {
|
||||||
|
// const wishlistItems = await prisma.wishlistItem.findMany({
|
||||||
|
// where: { userId: req.user.id },
|
||||||
|
// orderBy: { createdAt: 'desc' },
|
||||||
|
// });
|
||||||
|
|
||||||
|
// res.json({
|
||||||
|
// success: true,
|
||||||
|
// data: { wishlist: wishlistItems },
|
||||||
|
// });
|
||||||
|
// } catch (error) {
|
||||||
|
// next(error);
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
|
||||||
|
router.get('/wishlist', protect, wishlist.getWishlist);
|
||||||
|
|
||||||
|
// @desc Add item to wishlist
|
||||||
|
// @route POST /api/users/wishlist
|
||||||
|
// @access Private
|
||||||
|
// router.post('/wishlist', protect, async (req, res, next) => {
|
||||||
|
// try {
|
||||||
|
// const { productId } = req.body;
|
||||||
|
|
||||||
|
// if (!productId) {
|
||||||
|
// return res.status(400).json({
|
||||||
|
// success: false,
|
||||||
|
// message: 'Product ID is required',
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // Check if item already exists in wishlist
|
||||||
|
// const existingItem = await prisma.wishlistItem.findUnique({
|
||||||
|
// where: {
|
||||||
|
// userId_productId: {
|
||||||
|
// userId: req.user.id,
|
||||||
|
// productId,
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
|
||||||
|
// if (existingItem) {
|
||||||
|
// return res.status(400).json({
|
||||||
|
// success: false,
|
||||||
|
// message: 'Item already in wishlist',
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
// const wishlistItem = await prisma.wishlistItem.create({
|
||||||
|
// data: {
|
||||||
|
// userId: req.user.id,
|
||||||
|
// productId,
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
|
||||||
|
// res.status(201).json({
|
||||||
|
// success: true,
|
||||||
|
// message: 'Item added to wishlist',
|
||||||
|
// data: { wishlistItem },
|
||||||
|
// });
|
||||||
|
// } catch (error) {
|
||||||
|
// next(error);
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
|
||||||
|
router.post('/wishlist', protect, wishlist.addToWishlist);
|
||||||
|
|
||||||
|
// @desc Remove item from wishlist
|
||||||
|
// @route DELETE /api/users/wishlist/:productId
|
||||||
|
// @access Private
|
||||||
|
// router.delete('/wishlist/:productId', protect, async (req, res, next) => {
|
||||||
|
// try {
|
||||||
|
// const { productId } = req.params;
|
||||||
|
|
||||||
|
// const wishlistItem = await prisma.wishlistItem.findUnique({
|
||||||
|
// where: {
|
||||||
|
// userId_productId: {
|
||||||
|
// userId: req.user.id,
|
||||||
|
// productId,
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
|
||||||
|
// if (!wishlistItem) {
|
||||||
|
// return res.status(404).json({
|
||||||
|
// success: false,
|
||||||
|
// message: 'Item not found in wishlist',
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
// await prisma.wishlistItem.delete({
|
||||||
|
// where: {
|
||||||
|
// userId_productId: {
|
||||||
|
// userId: req.user.id,
|
||||||
|
// productId,
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
|
||||||
|
// res.json({
|
||||||
|
// success: true,
|
||||||
|
// message: 'Item removed from wishlist',
|
||||||
|
// });
|
||||||
|
// } catch (error) {
|
||||||
|
// next(error);
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
|
||||||
|
router.delete('/wishlist/:productId', protect, wishlist.removeFromWishlist);
|
||||||
|
|
||||||
|
// @desc Get all users (Admin only)
|
||||||
|
// @route GET /api/users
|
||||||
|
// @access Private/Admin
|
||||||
|
// router.get('/', protect, authorize('ADMIN'), async (req, res, next) => {
|
||||||
|
// try {
|
||||||
|
// const { page = 1, limit = 20, role, search } = req.query;
|
||||||
|
// const skip = (page - 1) * limit;
|
||||||
|
|
||||||
|
// const where = {};
|
||||||
|
// if (role) where.role = role;
|
||||||
|
// if (search) {
|
||||||
|
// where.OR = [
|
||||||
|
// { email: { contains: search, mode: 'insensitive' } },
|
||||||
|
// { firstName: { contains: search, mode: 'insensitive' } },
|
||||||
|
// { lastName: { contains: search, mode: 'insensitive' } },
|
||||||
|
// { username: { contains: search, mode: 'insensitive' } },
|
||||||
|
// ];
|
||||||
|
// }
|
||||||
|
|
||||||
|
// const [users, total] = await Promise.all([
|
||||||
|
// prisma.user.findMany({
|
||||||
|
// where,
|
||||||
|
// select: {
|
||||||
|
// id: true,
|
||||||
|
// email: true,
|
||||||
|
// username: true,
|
||||||
|
// firstName: true,
|
||||||
|
// lastName: true,
|
||||||
|
// role: true,
|
||||||
|
// isVerified: true,
|
||||||
|
// isActive: true,
|
||||||
|
// createdAt: true,
|
||||||
|
// lastLoginAt: true,
|
||||||
|
// },
|
||||||
|
// orderBy: { createdAt: 'desc' },
|
||||||
|
// skip: parseInt(skip),
|
||||||
|
// take: parseInt(limit),
|
||||||
|
// }),
|
||||||
|
// prisma.user.count({ where }),
|
||||||
|
// ]);
|
||||||
|
|
||||||
|
// res.json({
|
||||||
|
// success: true,
|
||||||
|
// data: {
|
||||||
|
// users,
|
||||||
|
// pagination: {
|
||||||
|
// page: parseInt(page),
|
||||||
|
// limit: parseInt(limit),
|
||||||
|
// total,
|
||||||
|
// pages: Math.ceil(total / limit),
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
// } catch (error) {
|
||||||
|
// next(error);
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
router.get('/', protect, authorize('ADMIN'), adminUsers.getAllUsers);
|
||||||
|
|
||||||
|
// Get user's shopping cart
|
||||||
|
router.get('/my-cart', protect, cart.getCart);
|
||||||
|
|
||||||
|
// Add product to cart
|
||||||
|
router.post('/my-cart/add-item', protect, cart.addToCart);
|
||||||
|
|
||||||
|
// Update item quantity in cart
|
||||||
|
router.put('/my-cart/update-item/:productId', protect, cart.updateQuantity);
|
||||||
|
|
||||||
|
// Remove item from cart
|
||||||
|
router.delete('/my-cart/remove-item/:productId', protect, cart.removeFromCart);
|
||||||
|
|
||||||
|
// Clear all items from cart
|
||||||
|
// router.delete('/my-cart/clear', protect, cart.clearCart);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
393
src/routes/wardrobe.js
Normal file
393
src/routes/wardrobe.js
Normal file
@@ -0,0 +1,393 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const Wardrobe = require('../models/mongodb/Wardrobe');
|
||||||
|
const { protect, authorize } = require('../middleware/auth');
|
||||||
|
|
||||||
|
|
||||||
|
const { getWardrobe, updateWardrobe } = require('../controllers/wardrobe/wardrobeMainController');
|
||||||
|
const { addItem, updateItem, removeItem, getItemsByCategory } = require('../controllers/wardrobe/wardrobeItemController');
|
||||||
|
const { searchItems } = require('../controllers/wardrobe/wardrobeSearchController');
|
||||||
|
const { getRecommendations } = require('../controllers/wardrobe/wardrobeRecommendationController');
|
||||||
|
const { getStats } = require('../controllers/wardrobe/wardrobeStatsController');
|
||||||
|
const { getPublicWardrobes, getPublicWardrobeById } = require('../controllers/wardrobe/wardrobePublicController');
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// @desc Get user's wardrobe
|
||||||
|
// @route GET /api/wardrobe
|
||||||
|
// @access Private
|
||||||
|
// router.get('/', protect, async (req, res, next) => {
|
||||||
|
// try {
|
||||||
|
// let wardrobe = await Wardrobe.findByUserId(req.user.id);
|
||||||
|
|
||||||
|
// if (!wardrobe) {
|
||||||
|
// // Create wardrobe if it doesn't exist
|
||||||
|
// wardrobe = new Wardrobe({
|
||||||
|
// userId: req.user.id,
|
||||||
|
// name: 'My Wardrobe',
|
||||||
|
// });
|
||||||
|
// await wardrobe.save();
|
||||||
|
// }
|
||||||
|
|
||||||
|
// res.json({
|
||||||
|
// success: true,
|
||||||
|
// data: { wardrobe },
|
||||||
|
// });
|
||||||
|
// } catch (error) {
|
||||||
|
// next(error);
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
|
||||||
|
router.get('/', protect, getWardrobe);
|
||||||
|
|
||||||
|
// @desc Update wardrobe details
|
||||||
|
// @route PUT /api/wardrobe
|
||||||
|
// @access Private
|
||||||
|
// router.put('/', protect, async (req, res, next) => {
|
||||||
|
// try {
|
||||||
|
// const { name, description, isPublic, shareSettings } = req.body;
|
||||||
|
|
||||||
|
// let wardrobe = await Wardrobe.findByUserId(req.user.id);
|
||||||
|
|
||||||
|
// if (!wardrobe) {
|
||||||
|
// wardrobe = new Wardrobe({
|
||||||
|
// userId: req.user.id,
|
||||||
|
// name: name || 'My Wardrobe',
|
||||||
|
// description,
|
||||||
|
// isPublic: isPublic || false,
|
||||||
|
// shareSettings: shareSettings || { allowViewing: false, allowRecommendations: false },
|
||||||
|
// });
|
||||||
|
// } else {
|
||||||
|
// wardrobe.name = name || wardrobe.name;
|
||||||
|
// wardrobe.description = description;
|
||||||
|
// wardrobe.isPublic = isPublic !== undefined ? isPublic : wardrobe.isPublic;
|
||||||
|
// wardrobe.shareSettings = shareSettings || wardrobe.shareSettings;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// await wardrobe.save();
|
||||||
|
|
||||||
|
// res.json({
|
||||||
|
// success: true,
|
||||||
|
// message: 'Wardrobe updated successfully',
|
||||||
|
// data: { wardrobe },
|
||||||
|
// });
|
||||||
|
// } catch (error) {
|
||||||
|
// next(error);
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
|
||||||
|
router.put('/', protect, updateWardrobe);
|
||||||
|
|
||||||
|
// @desc Add item to wardrobe
|
||||||
|
// @route POST /api/wardrobe/items
|
||||||
|
// @access Private
|
||||||
|
// router.post('/items', protect, async (req, res, next) => {
|
||||||
|
// try {
|
||||||
|
// const {
|
||||||
|
// name,
|
||||||
|
// description,
|
||||||
|
// category,
|
||||||
|
// subcategory,
|
||||||
|
// brand,
|
||||||
|
// color,
|
||||||
|
// size,
|
||||||
|
// material,
|
||||||
|
// condition,
|
||||||
|
// images,
|
||||||
|
// userTags,
|
||||||
|
// } = req.body;
|
||||||
|
|
||||||
|
// let wardrobe = await Wardrobe.findByUserId(req.user.id);
|
||||||
|
|
||||||
|
// if (!wardrobe) {
|
||||||
|
// wardrobe = new Wardrobe({
|
||||||
|
// userId: req.user.id,
|
||||||
|
// name: 'My Wardrobe',
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
// const itemData = {
|
||||||
|
// name,
|
||||||
|
// description,
|
||||||
|
// category,
|
||||||
|
// subcategory,
|
||||||
|
// brand,
|
||||||
|
// color,
|
||||||
|
// size,
|
||||||
|
// material,
|
||||||
|
// condition,
|
||||||
|
// images: images || [],
|
||||||
|
// userTags: userTags || [],
|
||||||
|
// };
|
||||||
|
|
||||||
|
// await wardrobe.addItem(itemData);
|
||||||
|
|
||||||
|
// res.status(201).json({
|
||||||
|
// success: true,
|
||||||
|
// message: 'Item added to wardrobe successfully',
|
||||||
|
// data: { wardrobe },
|
||||||
|
// });
|
||||||
|
// } catch (error) {
|
||||||
|
// next(error);
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
|
||||||
|
router.post('/items', protect, addItem);
|
||||||
|
|
||||||
|
// @desc Update wardrobe item
|
||||||
|
// @route PUT /api/wardrobe/items/:itemId
|
||||||
|
// @access Private
|
||||||
|
// router.put('/items/:itemId', protect, async (req, res, next) => {
|
||||||
|
// try {
|
||||||
|
// const { itemId } = req.params;
|
||||||
|
// const updateData = req.body;
|
||||||
|
|
||||||
|
// const wardrobe = await Wardrobe.findByUserId(req.user.id);
|
||||||
|
|
||||||
|
// if (!wardrobe) {
|
||||||
|
// return res.status(404).json({
|
||||||
|
// success: false,
|
||||||
|
// message: 'Wardrobe not found',
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
// await wardrobe.updateItem(itemId, updateData);
|
||||||
|
|
||||||
|
// res.json({
|
||||||
|
// success: true,
|
||||||
|
// message: 'Item updated successfully',
|
||||||
|
// data: { wardrobe },
|
||||||
|
// });
|
||||||
|
// } catch (error) {
|
||||||
|
// next(error);
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
router.put('/items/:itemId', protect, updateItem);
|
||||||
|
|
||||||
|
// @desc Remove item from wardrobe
|
||||||
|
// @route DELETE /api/wardrobe/items/:itemId
|
||||||
|
// @access Private
|
||||||
|
// router.delete('/items/:itemId', protect, async (req, res, next) => {
|
||||||
|
// try {
|
||||||
|
// const { itemId } = req.params;
|
||||||
|
|
||||||
|
// const wardrobe = await Wardrobe.findByUserId(req.user.id);
|
||||||
|
|
||||||
|
// if (!wardrobe) {
|
||||||
|
// return res.status(404).json({
|
||||||
|
// success: false,
|
||||||
|
// message: 'Wardrobe not found',
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
// await wardrobe.removeItem(itemId);
|
||||||
|
|
||||||
|
// res.json({
|
||||||
|
// success: true,
|
||||||
|
// message: 'Item removed from wardrobe successfully',
|
||||||
|
// data: { wardrobe },
|
||||||
|
// });
|
||||||
|
// } catch (error) {
|
||||||
|
// next(error);
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
|
||||||
|
router.delete('/items/:itemId', protect, removeItem);
|
||||||
|
|
||||||
|
// @desc Get items by category
|
||||||
|
// @route GET /api/wardrobe/items/category/:category
|
||||||
|
// @access Private
|
||||||
|
// router.get('/items/category/:category', protect, async (req, res, next) => {
|
||||||
|
// try {
|
||||||
|
// const { category } = req.params;
|
||||||
|
|
||||||
|
// const wardrobe = await Wardrobe.findByUserId(req.user.id);
|
||||||
|
|
||||||
|
// if (!wardrobe) {
|
||||||
|
// return res.status(404).json({
|
||||||
|
// success: false,
|
||||||
|
// message: 'Wardrobe not found',
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
// const items = wardrobe.getItemsByCategory(category);
|
||||||
|
|
||||||
|
// res.json({
|
||||||
|
// success: true,
|
||||||
|
// data: { items },
|
||||||
|
// });
|
||||||
|
// } catch (error) {
|
||||||
|
// next(error);
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
|
||||||
|
router.get('/items/category/:category', protect, getItemsByCategory);
|
||||||
|
|
||||||
|
// @desc Search wardrobe items
|
||||||
|
// @route GET /api/wardrobe/items/search
|
||||||
|
// @access Private
|
||||||
|
// router.get('/items/search', protect, async (req, res, next) => {
|
||||||
|
// try {
|
||||||
|
// const { query, tags, category } = req.query;
|
||||||
|
|
||||||
|
// const wardrobe = await Wardrobe.findByUserId(req.user.id);
|
||||||
|
|
||||||
|
// if (!wardrobe) {
|
||||||
|
// return res.status(404).json({
|
||||||
|
// success: false,
|
||||||
|
// message: 'Wardrobe not found',
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
// let items = wardrobe.items.filter(item => item.isActive);
|
||||||
|
|
||||||
|
// // Filter by category
|
||||||
|
// if (category) {
|
||||||
|
// items = items.filter(item => item.category === category);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // Filter by tags
|
||||||
|
// if (tags) {
|
||||||
|
// const tagArray = tags.split(',');
|
||||||
|
// items = items.filter(item =>
|
||||||
|
// tagArray.some(tag =>
|
||||||
|
// item.aiTags.includes(tag) || item.userTags.includes(tag)
|
||||||
|
// )
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // Search by query
|
||||||
|
// if (query) {
|
||||||
|
// const searchTerm = query.toLowerCase();
|
||||||
|
// items = items.filter(item =>
|
||||||
|
// item.name.toLowerCase().includes(searchTerm) ||
|
||||||
|
// item.brand?.toLowerCase().includes(searchTerm) ||
|
||||||
|
// item.description?.toLowerCase().includes(searchTerm)
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
|
||||||
|
// res.json({
|
||||||
|
// success: true,
|
||||||
|
// data: { items },
|
||||||
|
// });
|
||||||
|
// } catch (error) {
|
||||||
|
// next(error);
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
router.get('/items/search', protect, searchItems);
|
||||||
|
|
||||||
|
// @desc Generate outfit recommendations
|
||||||
|
// @route GET /api/wardrobe/recommendations
|
||||||
|
// @access Private
|
||||||
|
// router.get('/recommendations', protect, async (req, res, next) => {
|
||||||
|
// try {
|
||||||
|
// const wardrobe = await Wardrobe.findByUserId(req.user.id);
|
||||||
|
|
||||||
|
// if (!wardrobe) {
|
||||||
|
// return res.status(404).json({
|
||||||
|
// success: false,
|
||||||
|
// message: 'Wardrobe not found',
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
// const recommendations = wardrobe.generateOutfitRecommendations();
|
||||||
|
|
||||||
|
// res.json({
|
||||||
|
// success: true,
|
||||||
|
// data: { recommendations },
|
||||||
|
// });
|
||||||
|
// } catch (error) {
|
||||||
|
// next(error);
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
router.get('/recommendations', protect, getRecommendations);
|
||||||
|
|
||||||
|
// @desc Get wardrobe statistics
|
||||||
|
// @route GET /api/wardrobe/stats
|
||||||
|
// @access Private
|
||||||
|
// router.get('/stats', protect, async (req, res, next) => {
|
||||||
|
// try {
|
||||||
|
// const wardrobe = await Wardrobe.findByUserId(req.user.id);
|
||||||
|
|
||||||
|
// if (!wardrobe) {
|
||||||
|
// return res.status(404).json({
|
||||||
|
// success: false,
|
||||||
|
// message: 'Wardrobe not found',
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
// const stats = {
|
||||||
|
// totalItems: wardrobe.totalItems,
|
||||||
|
// categoryCounts: wardrobe.categoryCounts,
|
||||||
|
// recentItems: wardrobe.items
|
||||||
|
// .sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
|
||||||
|
// .slice(0, 5),
|
||||||
|
// mostWornItems: wardrobe.items
|
||||||
|
// .sort((a, b) => (b.purchaseCount || 0) - (a.purchaseCount || 0))
|
||||||
|
// .slice(0, 5),
|
||||||
|
// };
|
||||||
|
|
||||||
|
// res.json({
|
||||||
|
// success: true,
|
||||||
|
// data: { stats },
|
||||||
|
// });
|
||||||
|
// } catch (error) {
|
||||||
|
// next(error);
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
router.get('/stats', protect, getStats);
|
||||||
|
|
||||||
|
// @desc Get public wardrobes
|
||||||
|
// @route GET /api/wardrobe/public
|
||||||
|
// @access Public
|
||||||
|
// router.get('/public', async (req, res, next) => {
|
||||||
|
// try {
|
||||||
|
// const { page = 1, limit = 20 } = req.query;
|
||||||
|
// const skip = (page - 1) * limit;
|
||||||
|
|
||||||
|
// const wardrobes = await Wardrobe.findPublicWardrobes(
|
||||||
|
// parseInt(limit),
|
||||||
|
// parseInt(skip)
|
||||||
|
// );
|
||||||
|
|
||||||
|
// res.json({
|
||||||
|
// success: true,
|
||||||
|
// data: { wardrobes },
|
||||||
|
// });
|
||||||
|
// } catch (error) {
|
||||||
|
// next(error);
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
|
||||||
|
router.get('/public', getPublicWardrobes);
|
||||||
|
|
||||||
|
// @desc Get public wardrobe by ID
|
||||||
|
// @route GET /api/wardrobe/public/:id
|
||||||
|
// @access Public
|
||||||
|
// router.get('/public/:id', async (req, res, next) => {
|
||||||
|
// try {
|
||||||
|
// const wardrobe = await Wardrobe.findOne({
|
||||||
|
// _id: req.params.id,
|
||||||
|
// isPublic: true,
|
||||||
|
// });
|
||||||
|
|
||||||
|
// if (!wardrobe) {
|
||||||
|
// return res.status(404).json({
|
||||||
|
// success: false,
|
||||||
|
// message: 'Wardrobe not found',
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
// res.json({
|
||||||
|
// success: true,
|
||||||
|
// data: { wardrobe },
|
||||||
|
// });
|
||||||
|
// } catch (error) {
|
||||||
|
// next(error);
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
|
||||||
|
router.get('/public/:id', getPublicWardrobeById);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
149
src/scripts/addStockFieldToProducts.js
Normal file
149
src/scripts/addStockFieldToProducts.js
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
// scripts/addStockFieldToProducts.js - MIGRATION SCRIPT
|
||||||
|
|
||||||
|
const mongoose = require('mongoose');
|
||||||
|
require('dotenv').config();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add stock field to all existing products in MongoDB
|
||||||
|
*/
|
||||||
|
async function addStockFieldToProducts() {
|
||||||
|
try {
|
||||||
|
console.log('🔄 Starting stock field migration...\n');
|
||||||
|
|
||||||
|
// Connect to MongoDB
|
||||||
|
await mongoose.connect(process.env.MONGODB_URI || process.env.MONGO_URI);
|
||||||
|
console.log('✅ Connected to MongoDB\n');
|
||||||
|
|
||||||
|
const db = mongoose.connection.db;
|
||||||
|
const productsCollection = db.collection('products');
|
||||||
|
|
||||||
|
// Get all products
|
||||||
|
const allProducts = await productsCollection.find({}).toArray();
|
||||||
|
console.log(`📦 Found ${allProducts.length} total products\n`);
|
||||||
|
|
||||||
|
// Separate products by variant status
|
||||||
|
const withVariants = allProducts.filter(p => p.hasVariants && p.variants?.length > 0);
|
||||||
|
const withoutVariants = allProducts.filter(p => !p.hasVariants || !p.variants?.length);
|
||||||
|
|
||||||
|
console.log(` ${withVariants.length} products with variants`);
|
||||||
|
console.log(` ${withoutVariants.length} products without variants\n`);
|
||||||
|
|
||||||
|
let updated = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
|
||||||
|
// Update products WITHOUT variants
|
||||||
|
console.log('🔄 Adding stock field to simple products...\n');
|
||||||
|
|
||||||
|
for (const product of withoutVariants) {
|
||||||
|
// Skip if stock field already exists
|
||||||
|
if (product.stock !== undefined && product.stock !== null) {
|
||||||
|
console.log(` ⏭️ ${product.name}: Already has stock (${product.stock})`);
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add stock field (default 50 for existing products)
|
||||||
|
const DEFAULT_STOCK = 50;
|
||||||
|
|
||||||
|
await productsCollection.updateOne(
|
||||||
|
{ _id: product._id },
|
||||||
|
{
|
||||||
|
$set: {
|
||||||
|
stock: DEFAULT_STOCK,
|
||||||
|
trackInventory: true,
|
||||||
|
updatedAt: new Date()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(` ✅ ${product.name}: stock = ${DEFAULT_STOCK}`);
|
||||||
|
updated++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For products WITH variants, set stock to 0 (they use variant inventory)
|
||||||
|
console.log(`\n🔄 Setting stock=0 for variant products...\n`);
|
||||||
|
|
||||||
|
for (const product of withVariants) {
|
||||||
|
await productsCollection.updateOne(
|
||||||
|
{ _id: product._id },
|
||||||
|
{
|
||||||
|
$set: {
|
||||||
|
stock: 0,
|
||||||
|
trackInventory: false,
|
||||||
|
updatedAt: new Date()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(` ✅ ${product.name}: stock = 0 (uses variants)`);
|
||||||
|
updated++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verification
|
||||||
|
console.log(`\n📊 Verifying migration...\n`);
|
||||||
|
|
||||||
|
const verification = await productsCollection.aggregate([
|
||||||
|
{
|
||||||
|
$group: {
|
||||||
|
_id: null,
|
||||||
|
totalProducts: { $sum: 1 },
|
||||||
|
withStock: {
|
||||||
|
$sum: {
|
||||||
|
$cond: [{ $ifNull: ['$stock', false] }, 1, 0]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
avgStock: { $avg: '$stock' },
|
||||||
|
totalStock: { $sum: '$stock' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]).toArray();
|
||||||
|
|
||||||
|
if (verification.length > 0) {
|
||||||
|
const stats = verification[0];
|
||||||
|
console.log(` Total Products: ${stats.totalProducts}`);
|
||||||
|
console.log(` With Stock Field: ${stats.withStock}`);
|
||||||
|
console.log(` Average Stock: ${Math.round(stats.avgStock || 0)}`);
|
||||||
|
console.log(` Total Stock: ${stats.totalStock || 0}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n✅ Migration completed!`);
|
||||||
|
console.log(` Updated: ${updated} products`);
|
||||||
|
console.log(` Skipped: ${skipped} products (already had stock)\n`);
|
||||||
|
|
||||||
|
// Stock distribution
|
||||||
|
const stockDistribution = await productsCollection.aggregate([
|
||||||
|
{
|
||||||
|
$bucket: {
|
||||||
|
groupBy: '$stock',
|
||||||
|
boundaries: [0, 1, 6, 11, 51],
|
||||||
|
default: 'Other',
|
||||||
|
output: {
|
||||||
|
count: { $sum: 1 },
|
||||||
|
products: { $push: '$name' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]).toArray();
|
||||||
|
|
||||||
|
console.log('📈 Stock Distribution:');
|
||||||
|
stockDistribution.forEach(bucket => {
|
||||||
|
const range = bucket._id === 'Other' ? 'Other' :
|
||||||
|
bucket._id === 0 ? '0 (Out of Stock)' :
|
||||||
|
bucket._id === 1 ? '1-5 (Critical)' :
|
||||||
|
bucket._id === 6 ? '6-10 (Low)' :
|
||||||
|
'11-50 (In Stock)';
|
||||||
|
console.log(` ${range}: ${bucket.count} products`);
|
||||||
|
});
|
||||||
|
|
||||||
|
await mongoose.connection.close();
|
||||||
|
console.log('\n✅ Database connection closed');
|
||||||
|
process.exit(0);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('\n❌ Migration error:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run migration
|
||||||
|
addStockFieldToProducts();
|
||||||
48
src/scripts/migrateCategoryIds.js
Normal file
48
src/scripts/migrateCategoryIds.js
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
const mongoose = require('mongoose');
|
||||||
|
const Product = require('../models/mongodb/Product');
|
||||||
|
require('dotenv').config();
|
||||||
|
|
||||||
|
const migrateCategoryIds = async () => {
|
||||||
|
try {
|
||||||
|
// Connect to MongoDB
|
||||||
|
await mongoose.connect(process.env.MONGODB_URI || 'your_mongodb_connection_string');
|
||||||
|
console.log('Connected to MongoDB');
|
||||||
|
|
||||||
|
// Map old category IDs to new ones
|
||||||
|
const categoryMapping = {
|
||||||
|
'Clothing': 'cmiu33j770005141mz54xgsqe', // Fashion category
|
||||||
|
'68c123e87e7f9a9b8b123456': 'cmiu34dfg0009141mn8r1dujd', // Men Clothing
|
||||||
|
'68c123e87e7f9a9b8b123457': 'cmiu355i2000b141m2o7aqlb2', // Women Clothing
|
||||||
|
'68c123e87e7f9a9b8b123458': 'cmiu3a7je000l141mwx9boup4', // Western Wear
|
||||||
|
'68c123e87e7f9a9b8b123459': 'cmiu39ncw000j141mxizow1p2', // Lehengas
|
||||||
|
'68c123e87e7f9a9b8b123460': 'cmiu384il000f141m6obcit4u', // Sarees
|
||||||
|
'68c123e87e7f9a9b8b123461': 'cmiu39ncw000j141mxizow1p2', // Lehengas
|
||||||
|
'68c123e87e7f9a9b8b123462': 'cmiu3cuwy000t141mkt4weuy5', // Ethnic Wear
|
||||||
|
'68c123e87e7f9a9b8b123463': 'cmiu3a7je000l141mwx9boup4', // Western Wear
|
||||||
|
'cmh4io0fv0001145t4057y8dw': 'cmiu34dfg0009141mn8r1dujd', // Men Clothing
|
||||||
|
};
|
||||||
|
|
||||||
|
let totalUpdated = 0;
|
||||||
|
|
||||||
|
for (const [oldId, newId] of Object.entries(categoryMapping)) {
|
||||||
|
const result = await Product.updateMany(
|
||||||
|
{ category: oldId },
|
||||||
|
{ $set: { category: newId } }
|
||||||
|
);
|
||||||
|
totalUpdated += result.modifiedCount;
|
||||||
|
console.log(`✅ Updated ${result.modifiedCount} products from ${oldId} to ${newId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n🎉 Migration complete! Total products updated: ${totalUpdated}`);
|
||||||
|
|
||||||
|
// Close connection
|
||||||
|
await mongoose.connection.close();
|
||||||
|
console.log('MongoDB connection closed');
|
||||||
|
process.exit(0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Migration failed:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
migrateCategoryIds();
|
||||||
149
src/server.js
Normal file
149
src/server.js
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
require('dotenv').config();
|
||||||
|
// import uploadRoutes from "./routes/upload.routes";
|
||||||
|
const uploadRoutes = require('./routes/upload.routes');
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const cors = require('cors');
|
||||||
|
const helmet = require('helmet');
|
||||||
|
const morgan = require('morgan');
|
||||||
|
const cookieParser = require('cookie-parser');
|
||||||
|
|
||||||
|
const {
|
||||||
|
initializeDatabases,
|
||||||
|
closeDatabaseConnections,
|
||||||
|
} = require('./config/database');
|
||||||
|
const { errorHandler, notFound } = require('./middleware/errorHandler');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const PORT = process.env.PORT || 3000;
|
||||||
|
|
||||||
|
// Security middleware
|
||||||
|
app.use(
|
||||||
|
helmet({
|
||||||
|
contentSecurityPolicy: false, // Disable for API
|
||||||
|
crossOriginEmbedderPolicy: false,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const allowedOrigins = process.env.CORS_ORIGIN
|
||||||
|
? process.env.CORS_ORIGIN.split(',').map(origin => origin.trim())
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const corsOptions = {
|
||||||
|
origin: function (origin, callback) {
|
||||||
|
// Allow requests with no origin (like Postman, mobile apps)
|
||||||
|
if (!origin) return callback(null, true);
|
||||||
|
|
||||||
|
if (allowedOrigins.includes(origin)) {
|
||||||
|
callback(null, true);
|
||||||
|
} else {
|
||||||
|
callback(new Error('Not allowed by CORS'));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
credentials: true,
|
||||||
|
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
|
||||||
|
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'],
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
app.use(cors(corsOptions));
|
||||||
|
|
||||||
|
// Body parsing middleware
|
||||||
|
app.use(express.json({ limit: '10mb' }));
|
||||||
|
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
|
||||||
|
app.use(cookieParser());
|
||||||
|
|
||||||
|
// Logging middleware
|
||||||
|
if (process.env.NODE_ENV !== 'test') {
|
||||||
|
app.use(morgan('combined'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Health check endpoint
|
||||||
|
app.get('/health', async (req, res) => {
|
||||||
|
try {
|
||||||
|
res.status(200).json({
|
||||||
|
status: 'SUCCESS',
|
||||||
|
message: 'Vaishnavi Creation API is running successfully 🚀',
|
||||||
|
service: 'vaishnavi-backend',
|
||||||
|
version: '1.0.0',
|
||||||
|
environment: process.env.NODE_ENV,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
uptime_seconds: process.uptime(),
|
||||||
|
memory_usage: process.memoryUsage().rss, // RAM usage
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({
|
||||||
|
status: 'ERROR',
|
||||||
|
message: 'Health check failed',
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// API Routes
|
||||||
|
app.use('/api/auth', require('./routes/auth'));
|
||||||
|
app.use('/api/users', require('./routes/users'));
|
||||||
|
app.use('/api/products', require('./routes/products'));
|
||||||
|
app.use('/api/orders', require('./routes/orders'));
|
||||||
|
app.use('/api/wardrobe', require('./routes/wardrobe'));
|
||||||
|
app.use('/api/delivery', require('./routes/deliveryRoutes'));
|
||||||
|
app.use('/api/coupons', require('./routes/couponRoutes'));
|
||||||
|
app.use('/api/admin', require('./routes/admin'));
|
||||||
|
|
||||||
|
app.use('/api/admin/reports', require('./routes/reports'));
|
||||||
|
app.use('/api/payments', require('./routes/paymentRoutes'));
|
||||||
|
// Upload route
|
||||||
|
app.use('/api', uploadRoutes);
|
||||||
|
|
||||||
|
// Root endpoint
|
||||||
|
app.get('/', (req, res) => {
|
||||||
|
res.json({
|
||||||
|
message: 'Vaishnavi Creation API',
|
||||||
|
version: '1.0.0',
|
||||||
|
documentation: '/api/docs',
|
||||||
|
health: '/health',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Error handling middleware (must be last)
|
||||||
|
app.use(notFound);
|
||||||
|
app.use(errorHandler);
|
||||||
|
|
||||||
|
// Graceful shutdown
|
||||||
|
process.on('SIGTERM', async () => {
|
||||||
|
console.log('SIGTERM received, shutting down gracefully');
|
||||||
|
await closeDatabaseConnections();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('SIGINT', async () => {
|
||||||
|
console.log('SIGINT received, shutting down gracefully');
|
||||||
|
await closeDatabaseConnections();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start server
|
||||||
|
const startServer = async () => {
|
||||||
|
try {
|
||||||
|
// Initialize database connections
|
||||||
|
await initializeDatabases();
|
||||||
|
|
||||||
|
// Start the server
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`🚀 Server running on port ${PORT}`);
|
||||||
|
console.log(`📚 API Documentation: http://localhost:${PORT}/api/docs`);
|
||||||
|
console.log(`🏥 Health Check: http://localhost:${PORT}/health`);
|
||||||
|
console.log(`🌍 Environment: ${process.env.NODE_ENV}`);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Failed to start server:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only start server if this file is run directly
|
||||||
|
if (require.main === module) {
|
||||||
|
startServer();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = app;
|
||||||
348
src/services/authService.js
Normal file
348
src/services/authService.js
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
const bcrypt = require('bcrypt');
|
||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
const { prisma } = require('../config/database');
|
||||||
|
const sendEmail = require('../utils/mailer');
|
||||||
|
|
||||||
|
class AuthService {
|
||||||
|
// Generate JWT token
|
||||||
|
generateToken(payload) {
|
||||||
|
return jwt.sign(payload, process.env.JWT_SECRET, {
|
||||||
|
expiresIn: process.env.JWT_EXPIRES_IN || '7d',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate refresh token
|
||||||
|
generateRefreshToken(payload) {
|
||||||
|
return jwt.sign(payload, process.env.JWT_REFRESH_SECRET, {
|
||||||
|
expiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '30d',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash password
|
||||||
|
async hashPassword(password) {
|
||||||
|
const saltRounds = 12;
|
||||||
|
return await bcrypt.hash(password, saltRounds);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare password
|
||||||
|
async comparePassword(password, hashedPassword) {
|
||||||
|
return await bcrypt.compare(password, hashedPassword);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register new user
|
||||||
|
async register(userData) {
|
||||||
|
const { email, password, firstName, lastName, username, phone } = userData;
|
||||||
|
|
||||||
|
// Check if user already exists
|
||||||
|
const existingUser = await prisma.user.findFirst({
|
||||||
|
where: {
|
||||||
|
OR: [{ email }, ...(username ? [{ username }] : [])],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingUser) {
|
||||||
|
throw new Error('User with this email or username already exists');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash password
|
||||||
|
const passwordHash = await this.hashPassword(password);
|
||||||
|
|
||||||
|
// Create user
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email,
|
||||||
|
passwordHash,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
username,
|
||||||
|
phone,
|
||||||
|
// role: 'CUSTOMER',
|
||||||
|
role: userData.role || 'CUSTOMER',
|
||||||
|
isVerified: false,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
username: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
role: true,
|
||||||
|
isVerified: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate tokens
|
||||||
|
const token = this.generateToken({ id: user.id });
|
||||||
|
const refreshToken = this.generateRefreshToken({ id: user.id });
|
||||||
|
|
||||||
|
return {
|
||||||
|
user,
|
||||||
|
token,
|
||||||
|
refreshToken,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login user
|
||||||
|
async login(email, password) {
|
||||||
|
// Find user by email
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { email },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new Error('Invalid credentials');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user is active
|
||||||
|
if (!user.isActive) {
|
||||||
|
throw new Error('Account is deactivated');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify password
|
||||||
|
const isPasswordValid = await this.comparePassword(
|
||||||
|
password,
|
||||||
|
user.passwordHash
|
||||||
|
);
|
||||||
|
if (!isPasswordValid) {
|
||||||
|
throw new Error('Invalid credentials');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last login
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: { lastLoginAt: new Date() },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate tokens
|
||||||
|
const token = this.generateToken({ id: user.id });
|
||||||
|
const refreshToken = this.generateRefreshToken({ id: user.id });
|
||||||
|
|
||||||
|
// Return user data (without password)
|
||||||
|
const userData = {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
username: user.username,
|
||||||
|
firstName: user.firstName,
|
||||||
|
lastName: user.lastName,
|
||||||
|
role: user.role,
|
||||||
|
isVerified: user.isVerified,
|
||||||
|
avatar: user.avatar,
|
||||||
|
lastLoginAt: user.lastLoginAt,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
user: userData,
|
||||||
|
token,
|
||||||
|
refreshToken,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh token
|
||||||
|
async refreshToken(refreshToken) {
|
||||||
|
try {
|
||||||
|
const decoded = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET);
|
||||||
|
|
||||||
|
// Find user
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: decoded.id },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
username: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
role: true,
|
||||||
|
isVerified: true,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user || !user.isActive) {
|
||||||
|
throw new Error('Invalid refresh token');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new tokens
|
||||||
|
const newToken = this.generateToken({ id: user.id });
|
||||||
|
const newRefreshToken = this.generateRefreshToken({ id: user.id });
|
||||||
|
|
||||||
|
return {
|
||||||
|
token: newToken,
|
||||||
|
refreshToken: newRefreshToken,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error('Invalid refresh token');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Change password
|
||||||
|
async changePassword(userId, currentPassword, newPassword) {
|
||||||
|
// Find user
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new Error('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify current password
|
||||||
|
const isCurrentPasswordValid = await this.comparePassword(
|
||||||
|
currentPassword,
|
||||||
|
user.passwordHash
|
||||||
|
);
|
||||||
|
if (!isCurrentPasswordValid) {
|
||||||
|
throw new Error('Current password is incorrect');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash new password
|
||||||
|
const newPasswordHash = await this.hashPassword(newPassword);
|
||||||
|
|
||||||
|
// Update password
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: { passwordHash: newPasswordHash },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { message: 'Password changed successfully' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset password request
|
||||||
|
async requestPasswordReset(email) {
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { email },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
// Don't reveal if user exists or not
|
||||||
|
return { message: 'If the email exists, a reset link has been sent' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate reset token
|
||||||
|
const resetToken = jwt.sign(
|
||||||
|
{ id: user.id, type: 'password_reset' },
|
||||||
|
process.env.JWT_SECRET,
|
||||||
|
{ expiresIn: '1h' }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Use URL from env
|
||||||
|
const resetUrl = `${process.env.FRONTEND_URL}/reset-password?token=${resetToken}`;
|
||||||
|
|
||||||
|
// In a real application, you would:
|
||||||
|
// 1. Store the reset token in database with expiry
|
||||||
|
// 2. Send email with reset link
|
||||||
|
// 3. Use a proper email service
|
||||||
|
// Send email
|
||||||
|
await sendEmail(user.email, 'Reset Your Password', 'reset-password', {
|
||||||
|
firstName: user.firstName || '',
|
||||||
|
resetUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
// await sendEmail(user.email, 'Reset Your Password', html);
|
||||||
|
|
||||||
|
console.log(`Password reset token for ${email}: ${resetToken}`);
|
||||||
|
|
||||||
|
return { message: 'If the email exists, a reset link has been sent' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset password with token
|
||||||
|
async resetPassword(token, newPassword) {
|
||||||
|
try {
|
||||||
|
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||||
|
|
||||||
|
if (decoded.type !== 'password_reset') {
|
||||||
|
throw new Error('Invalid reset token');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find user
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: decoded.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new Error('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash new password
|
||||||
|
const passwordHash = await this.hashPassword(newPassword);
|
||||||
|
|
||||||
|
// Update password
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: { passwordHash },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { message: 'Password reset successfully' };
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error('Invalid or expired reset token');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify email
|
||||||
|
async verifyEmail(token) {
|
||||||
|
try {
|
||||||
|
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||||
|
|
||||||
|
if (decoded.type !== 'email_verification') {
|
||||||
|
throw new Error('Invalid verification token');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update user verification status
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: decoded.id },
|
||||||
|
data: { isVerified: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { message: 'Email verified successfully' };
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error('Invalid or expired verification token');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send verification email
|
||||||
|
async sendVerificationEmail(userId) {
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new Error('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.isVerified) {
|
||||||
|
throw new Error('Email already verified');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate verification token
|
||||||
|
const verificationToken = jwt.sign(
|
||||||
|
{ id: user.id, type: 'email_verification' },
|
||||||
|
process.env.JWT_SECRET,
|
||||||
|
{ expiresIn: '24h' }
|
||||||
|
);
|
||||||
|
const verificationUrl = `${process.env.FRONTEND_URL}/verify-email?token=${verificationToken}`;
|
||||||
|
|
||||||
|
await sendEmail(user.email, 'Verify Your Email', 'verify-email', {
|
||||||
|
firstName: user.firstName || '',
|
||||||
|
verificationUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
// await sendEmail(user.email, 'Verify Your Email', html);
|
||||||
|
// In a real application, you would send an email here
|
||||||
|
console.log(`Verification token for ${user.email}: ${verificationToken}`);
|
||||||
|
|
||||||
|
return { message: 'Verification email sent' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logout (invalidate token)
|
||||||
|
async logout(userId) {
|
||||||
|
// In a real application, you might want to:
|
||||||
|
// 1. Add token to a blacklist
|
||||||
|
// 2. Store invalidated tokens in Redis
|
||||||
|
// 3. Implement proper session management
|
||||||
|
|
||||||
|
return { message: 'Logged out successfully' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new AuthService();
|
||||||
266
src/services/deliveryEstimationService.js
Normal file
266
src/services/deliveryEstimationService.js
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
// services/deliveryEstimationService.js
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delivery Estimation Service
|
||||||
|
* Calculates estimated delivery dates based on:
|
||||||
|
* - User's pincode/location
|
||||||
|
* - Order placement time
|
||||||
|
* - Shipping method
|
||||||
|
* - Product availability
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Pincode to delivery days mapping (Indian postal system)
|
||||||
|
const PINCODE_ZONES = {
|
||||||
|
// Metro Cities (1-2 days)
|
||||||
|
METRO: {
|
||||||
|
pincodes: [
|
||||||
|
// Delhi NCR
|
||||||
|
/^110\d{3}$/, /^121\d{3}$/, /^122\d{3}$/, /^201\d{3}$/,
|
||||||
|
// Mumbai
|
||||||
|
/^400\d{3}$/, /^401\d{3}$/,
|
||||||
|
// Bangalore
|
||||||
|
/^560\d{3}$/,
|
||||||
|
// Chennai
|
||||||
|
/^600\d{3}$/,
|
||||||
|
// Hyderabad
|
||||||
|
/^500\d{3}$/,
|
||||||
|
// Kolkata
|
||||||
|
/^700\d{3}$/,
|
||||||
|
// Pune
|
||||||
|
/^411\d{3}$/,
|
||||||
|
// Ahmedabad
|
||||||
|
/^380\d{3}$/,
|
||||||
|
],
|
||||||
|
deliveryDays: { min: 1, max: 2 },
|
||||||
|
name: 'Metro Cities',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Tier 1 Cities (2-4 days)
|
||||||
|
TIER_1: {
|
||||||
|
pincodes: [
|
||||||
|
/^141\d{3}$/, // Ludhiana
|
||||||
|
/^160\d{3}$/, // Chandigarh
|
||||||
|
/^226\d{3}$/, // Lucknow
|
||||||
|
/^302\d{3}$/, // Jaipur
|
||||||
|
/^390\d{3}$/, // Vadodara
|
||||||
|
/^395\d{3}$/, // Surat
|
||||||
|
/^422\d{3}$/, // Nashik
|
||||||
|
/^440\d{3}$/, // Nagpur
|
||||||
|
/^462\d{3}$/, // Bhopal
|
||||||
|
/^482\d{3}$/, // Indore
|
||||||
|
/^492\d{3}$/, // Raipur
|
||||||
|
/^520\d{3}$/, // Vijayawada
|
||||||
|
/^530\d{3}$/, // Visakhapatnam
|
||||||
|
/^570\d{3}$/, // Mysore
|
||||||
|
/^641\d{3}$/, // Coimbatore
|
||||||
|
/^682\d{3}$/, // Kochi
|
||||||
|
/^695\d{3}$/, // Trivandrum
|
||||||
|
/^751\d{3}$/, // Bhubaneswar
|
||||||
|
/^781\d{3}$/, // Guwahati
|
||||||
|
/^800\d{3}$/, // Patna
|
||||||
|
],
|
||||||
|
deliveryDays: { min: 2, max: 4 },
|
||||||
|
name: 'Tier 1 Cities',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Tier 2 Cities (3-5 days)
|
||||||
|
TIER_2: {
|
||||||
|
pincodes: [
|
||||||
|
/^1[0-9]{5}$/, // North India (excluding metros)
|
||||||
|
/^2[0-9]{5}$/, // West India (excluding metros)
|
||||||
|
/^3[0-9]{5}$/, // Gujarat/Rajasthan
|
||||||
|
/^4[0-9]{5}$/, // Maharashtra (excluding metros)
|
||||||
|
/^5[0-9]{5}$/, // South India (excluding metros)
|
||||||
|
/^6[0-9]{5}$/, // Tamil Nadu/Kerala (excluding metros)
|
||||||
|
/^7[0-9]{5}$/, // East India
|
||||||
|
/^8[0-9]{5}$/, // Bihar/Jharkhand
|
||||||
|
],
|
||||||
|
deliveryDays: { min: 3, max: 5 },
|
||||||
|
name: 'Tier 2 Cities',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Remote Areas (5-7 days)
|
||||||
|
REMOTE: {
|
||||||
|
pincodes: [
|
||||||
|
/^17[0-9]{4}$/, // Himachal Pradesh
|
||||||
|
/^18[0-9]{4}$/, // J&K
|
||||||
|
/^19[0-9]{4}$/, // J&K
|
||||||
|
/^73[0-9]{4}$/, // Arunachal Pradesh
|
||||||
|
/^79[0-9]{4}$/, // Assam/Meghalaya
|
||||||
|
/^82[0-9]{4}$/, // Jharkhand (remote)
|
||||||
|
/^84[0-9]{4}$/, // Bihar (remote)
|
||||||
|
/^85[0-9]{4}$/, // Orissa (remote)
|
||||||
|
],
|
||||||
|
deliveryDays: { min: 5, max: 7 },
|
||||||
|
name: 'Remote Areas',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Default (4-6 days)
|
||||||
|
DEFAULT: {
|
||||||
|
deliveryDays: { min: 4, max: 6 },
|
||||||
|
name: 'Standard Delivery',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get delivery zone based on pincode
|
||||||
|
*/
|
||||||
|
function getDeliveryZone(pincode) {
|
||||||
|
const cleanPincode = pincode.replace(/\s+/g, '');
|
||||||
|
|
||||||
|
// Check Metro
|
||||||
|
if (PINCODE_ZONES.METRO.pincodes.some(pattern => pattern.test(cleanPincode))) {
|
||||||
|
return PINCODE_ZONES.METRO;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Tier 1
|
||||||
|
if (PINCODE_ZONES.TIER_1.pincodes.some(pattern => pattern.test(cleanPincode))) {
|
||||||
|
return PINCODE_ZONES.TIER_1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Tier 2
|
||||||
|
if (PINCODE_ZONES.TIER_2.pincodes.some(pattern => pattern.test(cleanPincode))) {
|
||||||
|
return PINCODE_ZONES.TIER_2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Remote
|
||||||
|
if (PINCODE_ZONES.REMOTE.pincodes.some(pattern => pattern.test(cleanPincode))) {
|
||||||
|
return PINCODE_ZONES.REMOTE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default
|
||||||
|
return PINCODE_ZONES.DEFAULT;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate estimated delivery date
|
||||||
|
*/
|
||||||
|
function calculateDeliveryDate(pincode, orderDate = new Date(), shippingMethod = 'STANDARD') {
|
||||||
|
const zone = getDeliveryZone(pincode);
|
||||||
|
let { min, max } = zone.deliveryDays;
|
||||||
|
|
||||||
|
// Adjust for shipping method
|
||||||
|
if (shippingMethod === 'EXPRESS') {
|
||||||
|
min = Math.max(1, min - 1);
|
||||||
|
max = Math.max(2, max - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate dates
|
||||||
|
const minDate = addBusinessDays(orderDate, min);
|
||||||
|
const maxDate = addBusinessDays(orderDate, max);
|
||||||
|
|
||||||
|
return {
|
||||||
|
zone: zone.name,
|
||||||
|
estimatedDays: { min, max },
|
||||||
|
estimatedDelivery: {
|
||||||
|
min: minDate,
|
||||||
|
max: maxDate,
|
||||||
|
formatted: formatDateRange(minDate, maxDate),
|
||||||
|
},
|
||||||
|
shippingMethod,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add business days (excluding Sundays)
|
||||||
|
*/
|
||||||
|
function addBusinessDays(date, days) {
|
||||||
|
let currentDate = new Date(date);
|
||||||
|
let addedDays = 0;
|
||||||
|
|
||||||
|
while (addedDays < days) {
|
||||||
|
currentDate.setDate(currentDate.getDate() + 1);
|
||||||
|
|
||||||
|
// Skip Sundays (0 = Sunday)
|
||||||
|
if (currentDate.getDay() !== 0) {
|
||||||
|
addedDays++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format date range for display
|
||||||
|
*/
|
||||||
|
function formatDateRange(minDate, maxDate) {
|
||||||
|
const options = {
|
||||||
|
weekday: 'short',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric'
|
||||||
|
};
|
||||||
|
|
||||||
|
const minFormatted = minDate.toLocaleDateString('en-IN', options);
|
||||||
|
const maxFormatted = maxDate.toLocaleDateString('en-IN', options);
|
||||||
|
|
||||||
|
// If same month
|
||||||
|
if (minDate.getMonth() === maxDate.getMonth()) {
|
||||||
|
return `${minDate.getDate()}-${maxDate.getDate()} ${minDate.toLocaleDateString('en-IN', { month: 'short' })}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${minFormatted} - ${maxFormatted}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get delivery estimation for checkout
|
||||||
|
*/
|
||||||
|
async function getDeliveryEstimation(pincode, shippingMethod = 'STANDARD') {
|
||||||
|
try {
|
||||||
|
const estimation = calculateDeliveryDate(pincode, new Date(), shippingMethod);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
...estimation,
|
||||||
|
message: `Estimated delivery by ${estimation.estimatedDelivery.formatted}`,
|
||||||
|
canDeliver: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Delivery estimation error:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: 'Unable to calculate delivery time',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if pincode is serviceable
|
||||||
|
*/
|
||||||
|
function isServiceable(pincode) {
|
||||||
|
const cleanPincode = pincode.replace(/\s+/g, '');
|
||||||
|
|
||||||
|
// Basic validation: Indian pincodes are 6 digits
|
||||||
|
if (!/^\d{6}$/.test(cleanPincode)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// All Indian pincodes are serviceable
|
||||||
|
// You can add specific non-serviceable pincodes here if needed
|
||||||
|
const nonServiceablePincodes = [
|
||||||
|
// Add any non-serviceable pincodes
|
||||||
|
];
|
||||||
|
|
||||||
|
return !nonServiceablePincodes.includes(cleanPincode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get delivery speed label
|
||||||
|
*/
|
||||||
|
function getDeliverySpeedLabel(days) {
|
||||||
|
if (days <= 2) return 'Express Delivery';
|
||||||
|
if (days <= 4) return 'Fast Delivery';
|
||||||
|
if (days <= 6) return 'Standard Delivery';
|
||||||
|
return 'Extended Delivery';
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
calculateDeliveryDate,
|
||||||
|
getDeliveryEstimation,
|
||||||
|
getDeliveryZone,
|
||||||
|
isServiceable,
|
||||||
|
getDeliverySpeedLabel,
|
||||||
|
addBusinessDays,
|
||||||
|
PINCODE_ZONES,
|
||||||
|
};
|
||||||
264
src/services/inventoryService.js
Normal file
264
src/services/inventoryService.js
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
// services/inventoryService.js - COMPLETE INVENTORY SYSTEM
|
||||||
|
const { prisma } = require('../config/database');
|
||||||
|
const Product = require('../models/mongodb/Product');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ✅ Auto-reduce stock when order is DELIVERED
|
||||||
|
*/
|
||||||
|
async function reduceStockOnDelivery(orderId) {
|
||||||
|
try {
|
||||||
|
const order = await prisma.order.findUnique({
|
||||||
|
where: { id: orderId },
|
||||||
|
include: { items: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!order || order.status !== 'DELIVERED') return null;
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
for (const item of order.items) {
|
||||||
|
const product = await Product.findById(item.productId);
|
||||||
|
if (!product) continue;
|
||||||
|
|
||||||
|
let previousStock, newStock;
|
||||||
|
|
||||||
|
if (product.hasVariants && product.variants?.length > 0) {
|
||||||
|
// ✅ Reduce from matching variant by SKU
|
||||||
|
previousStock = product.variants
|
||||||
|
.filter(v => v.isActive)
|
||||||
|
.reduce((sum, v) => sum + (v.inventory?.quantity || 0), 0);
|
||||||
|
|
||||||
|
const variantIndex = product.variants.findIndex(
|
||||||
|
v => v.sku === item.productSku
|
||||||
|
);
|
||||||
|
|
||||||
|
if (variantIndex !== -1) {
|
||||||
|
const currentQty =
|
||||||
|
product.variants[variantIndex].inventory?.quantity || 0;
|
||||||
|
product.variants[variantIndex].inventory.quantity = Math.max(
|
||||||
|
0,
|
||||||
|
currentQty - item.quantity
|
||||||
|
);
|
||||||
|
await product.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
newStock = product.variants
|
||||||
|
.filter(v => v.isActive)
|
||||||
|
.reduce((sum, v) => sum + (v.inventory?.quantity || 0), 0);
|
||||||
|
} else {
|
||||||
|
// ✅ Non-variant product
|
||||||
|
previousStock = product.stock || 0;
|
||||||
|
newStock = Math.max(0, previousStock - item.quantity);
|
||||||
|
await Product.findByIdAndUpdate(item.productId, {
|
||||||
|
stock: newStock,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.inventoryLog.create({
|
||||||
|
data: {
|
||||||
|
productId: item.productId,
|
||||||
|
productName: item.productName || product.name,
|
||||||
|
type: 'SOLD',
|
||||||
|
quantityChange: -item.quantity,
|
||||||
|
previousStock,
|
||||||
|
newStock,
|
||||||
|
orderId,
|
||||||
|
notes: `Order ${order.orderNumber} delivered`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
productId: item.productId,
|
||||||
|
productName: product.name,
|
||||||
|
reduced: item.quantity,
|
||||||
|
previousStock,
|
||||||
|
newStock,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Stock reduction error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ✅ Get low stock products
|
||||||
|
*/
|
||||||
|
async function getLowStockProducts(threshold = 10) {
|
||||||
|
try {
|
||||||
|
const products = await Product.find({ status: 'active' }).lean();
|
||||||
|
|
||||||
|
const withStock = products.map(product => {
|
||||||
|
let totalStock = 0;
|
||||||
|
|
||||||
|
if (product.hasVariants && product.variants?.length > 0) {
|
||||||
|
totalStock = product.variants
|
||||||
|
.filter(v => v.isActive)
|
||||||
|
.reduce((sum, v) => sum + (v.inventory?.quantity || 0), 0);
|
||||||
|
} else {
|
||||||
|
totalStock = product.stock || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
_id: product._id.toString(),
|
||||||
|
name: product.name,
|
||||||
|
slug: product.slug,
|
||||||
|
stock: totalStock,
|
||||||
|
basePrice: product.basePrice,
|
||||||
|
hasVariants: product.hasVariants,
|
||||||
|
status:
|
||||||
|
totalStock === 0
|
||||||
|
? 'OUT_OF_STOCK'
|
||||||
|
: totalStock <= 5
|
||||||
|
? 'CRITICAL'
|
||||||
|
: 'LOW',
|
||||||
|
displayImage: getProductImage(product),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return withStock
|
||||||
|
.filter(p => p.stock <= threshold)
|
||||||
|
.sort((a, b) => a.stock - b.stock)
|
||||||
|
.slice(0, threshold);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching low stock:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ✅ Get inventory stats for dashboard
|
||||||
|
*/
|
||||||
|
async function getInventoryStats() {
|
||||||
|
try {
|
||||||
|
const products = await Product.find({ status: 'active' }).lean();
|
||||||
|
|
||||||
|
let outOfStock = 0,
|
||||||
|
criticalStock = 0,
|
||||||
|
lowStock = 0,
|
||||||
|
inStock = 0;
|
||||||
|
|
||||||
|
products.forEach(product => {
|
||||||
|
let stock = 0;
|
||||||
|
|
||||||
|
if (product.hasVariants && product.variants?.length > 0) {
|
||||||
|
stock = product.variants
|
||||||
|
.filter(v => v.isActive)
|
||||||
|
.reduce((sum, v) => sum + (v.inventory?.quantity || 0), 0);
|
||||||
|
} else {
|
||||||
|
stock = product.stock || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stock === 0) outOfStock++;
|
||||||
|
else if (stock <= 5) criticalStock++;
|
||||||
|
else if (stock <= 10) lowStock++;
|
||||||
|
else inStock++;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalProducts: products.length,
|
||||||
|
outOfStock,
|
||||||
|
criticalStock,
|
||||||
|
lowStock,
|
||||||
|
inStock,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching inventory stats:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ✅ Manual stock adjustment (Admin)
|
||||||
|
*/
|
||||||
|
async function adjustStock(
|
||||||
|
productId,
|
||||||
|
variantSku = null,
|
||||||
|
quantity,
|
||||||
|
type,
|
||||||
|
notes,
|
||||||
|
adminId
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const product = await Product.findById(productId);
|
||||||
|
if (!product) throw new Error('Product not found');
|
||||||
|
|
||||||
|
let previousStock, newStock;
|
||||||
|
|
||||||
|
if (product.hasVariants && product.variants?.length > 0 && variantSku) {
|
||||||
|
// ✅ Adjust specific variant
|
||||||
|
const variantIndex = product.variants.findIndex(
|
||||||
|
v => v.sku === variantSku
|
||||||
|
);
|
||||||
|
if (variantIndex === -1) throw new Error('Variant not found');
|
||||||
|
|
||||||
|
const currentQty =
|
||||||
|
product.variants[variantIndex].inventory?.quantity || 0;
|
||||||
|
previousStock = currentQty;
|
||||||
|
|
||||||
|
if (type === 'ADD') newStock = currentQty + quantity;
|
||||||
|
else if (type === 'REMOVE') newStock = Math.max(0, currentQty - quantity);
|
||||||
|
else if (type === 'SET') newStock = quantity;
|
||||||
|
else throw new Error('Invalid type');
|
||||||
|
|
||||||
|
product.variants[variantIndex].inventory.quantity = newStock;
|
||||||
|
await product.save();
|
||||||
|
} else {
|
||||||
|
// ✅ Non-variant product
|
||||||
|
previousStock = product.stock || 0;
|
||||||
|
|
||||||
|
if (type === 'ADD') newStock = previousStock + quantity;
|
||||||
|
else if (type === 'REMOVE')
|
||||||
|
newStock = Math.max(0, previousStock - quantity);
|
||||||
|
else if (type === 'SET') newStock = quantity;
|
||||||
|
else throw new Error('Invalid type');
|
||||||
|
|
||||||
|
await Product.findByIdAndUpdate(productId, {
|
||||||
|
stock: newStock,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.inventoryLog.create({
|
||||||
|
data: {
|
||||||
|
productId,
|
||||||
|
productName: product.name,
|
||||||
|
type: type === 'ADD' ? 'RESTOCK' : 'ADJUSTMENT',
|
||||||
|
quantityChange: type === 'ADD' ? quantity : -quantity,
|
||||||
|
previousStock,
|
||||||
|
newStock,
|
||||||
|
notes: notes || `Manual ${type} by admin`,
|
||||||
|
adjustedBy: adminId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
productName: product.name,
|
||||||
|
previousStock,
|
||||||
|
newStock,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error adjusting stock:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProductImage(product) {
|
||||||
|
return (
|
||||||
|
product.images?.gallery?.[0] ||
|
||||||
|
product.images?.primary ||
|
||||||
|
product.variants?.[0]?.images?.[0] ||
|
||||||
|
'https://via.placeholder.com/300'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
reduceStockOnDelivery,
|
||||||
|
getLowStockProducts,
|
||||||
|
getInventoryStats,
|
||||||
|
adjustStock,
|
||||||
|
};
|
||||||
59
src/services/otpStore.js
Normal file
59
src/services/otpStore.js
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
// Simple in-memory OTP store
|
||||||
|
// Replace with Redis in production: await redis.setex(key, 300, otp)
|
||||||
|
const otpStore = new Map();
|
||||||
|
|
||||||
|
const OTP_EXPIRY_MS = 5 * 60 * 1000; // 5 minutes
|
||||||
|
|
||||||
|
// ✅ TEST OTP — works in any environment when used
|
||||||
|
const TEST_OTP = '123456';
|
||||||
|
const TEST_PHONES = ['9999999999']; // add any test phone numbers here
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
function saveOTP(phone, otp) {
|
||||||
|
otpStore.set(phone, {
|
||||||
|
otp,
|
||||||
|
expiresAt: Date.now() + OTP_EXPIRY_MS,
|
||||||
|
attempts: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyOTP(phone, inputOtp) {
|
||||||
|
// ✅ Master bypass OTP — always works in development
|
||||||
|
if (process.env.NODE_ENV === 'development' && inputOtp === TEST_OTP) {
|
||||||
|
console.log(`🔓 Test OTP used for ${phone}`);
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Test phone numbers skip WhatsApp entirely
|
||||||
|
if (TEST_PHONES.includes(phone) && inputOtp === TEST_OTP) {
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normal OTP verification below...
|
||||||
|
const record = otpStore.get(phone);
|
||||||
|
if (!record) return { valid: false, reason: 'OTP not found or expired' };
|
||||||
|
if (Date.now() > record.expiresAt) {
|
||||||
|
otpStore.delete(phone);
|
||||||
|
return { valid: false, reason: 'OTP expired' };
|
||||||
|
}
|
||||||
|
|
||||||
|
record.attempts++;
|
||||||
|
if (record.attempts > 3) {
|
||||||
|
otpStore.delete(phone);
|
||||||
|
return { valid: false, reason: 'Too many attempts' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (record.otp !== inputOtp) {
|
||||||
|
return { valid: false, reason: 'Invalid OTP' };
|
||||||
|
}
|
||||||
|
|
||||||
|
otpStore.delete(phone);
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearOTP(phone) {
|
||||||
|
otpStore.delete(phone);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { saveOTP, verifyOTP, clearOTP };
|
||||||
25
src/services/s3Upload.service.js
Normal file
25
src/services/s3Upload.service.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
// services/s3Upload.service.js
|
||||||
|
import { PutObjectCommand } from "@aws-sdk/client-s3";
|
||||||
|
import s3 from "../config/s3.js";
|
||||||
|
import fs from "fs";
|
||||||
|
|
||||||
|
export const uploadToS3 = async (file) => {
|
||||||
|
const fileStream = fs.createReadStream(file.path);
|
||||||
|
|
||||||
|
const key = `products/${Date.now()}-${file.originalname}`;
|
||||||
|
|
||||||
|
await s3.send(
|
||||||
|
new PutObjectCommand({
|
||||||
|
Bucket: process.env.AWS_S3_BUCKET,
|
||||||
|
Key: key,
|
||||||
|
Body: fileStream,
|
||||||
|
ContentType: file.mimetype,
|
||||||
|
ACL: "public-read",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// optional cleanup
|
||||||
|
fs.unlinkSync(file.path);
|
||||||
|
|
||||||
|
return `https://s3.sahasrarameta.tech/${process.env.AWS_S3_BUCKET}/${key}`;
|
||||||
|
};
|
||||||
63
src/services/wappconnectService.js
Normal file
63
src/services/wappconnectService.js
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
const axios = require('axios');
|
||||||
|
|
||||||
|
const TEST_PHONES = ['9999999999'];
|
||||||
|
const TEST_OTP = '123456';
|
||||||
|
|
||||||
|
// Generate 6-digit OTP
|
||||||
|
function generateOTP() {
|
||||||
|
return Math.floor(100000 + Math.random() * 900000).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send OTP via WhatsApp
|
||||||
|
async function sendOTP(phoneNumber, otp) {
|
||||||
|
// Skip real API for test phones
|
||||||
|
if (TEST_PHONES.includes(phoneNumber)) {
|
||||||
|
console.log(`🧪 Test phone — Use OTP: ${TEST_OTP}`);
|
||||||
|
return { success: true, test: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Format phone number to 12 digits (91XXXXXXXXXX)
|
||||||
|
const digits = phoneNumber.replace(/\D/g, '');
|
||||||
|
const formatted =
|
||||||
|
digits.startsWith('91') && digits.length === 12 ? digits : `91${digits}`;
|
||||||
|
|
||||||
|
// ✅ Same variable name as karaychakra: WAPP_TOKEN
|
||||||
|
const WAPP_TOKEN = process.env.WAPP_TOKEN;
|
||||||
|
// const message = `🔐 Your OTP is: *${otp}*\n\nValid for 5 minutes. Do not share with anyone.`;
|
||||||
|
const message = `🌸 *Vaishnavi Creation* 🌸
|
||||||
|
|
||||||
|
To continue your login, please use the OTP below:
|
||||||
|
|
||||||
|
🔑 *${otp}*
|
||||||
|
|
||||||
|
⏳ Valid for the next *5 minutes*.
|
||||||
|
|
||||||
|
If you did not request this OTP, please ignore this message.
|
||||||
|
|
||||||
|
Thank you for choosing Vaishnavi Creation. 💫`;
|
||||||
|
|
||||||
|
console.log('📞 Sending to:', formatted);
|
||||||
|
console.log(
|
||||||
|
'🔑 WAPP_TOKEN:',
|
||||||
|
WAPP_TOKEN ? WAPP_TOKEN.substring(0, 10) + '...' : '❌ MISSING'
|
||||||
|
);
|
||||||
|
|
||||||
|
// ✅ Exact same logic as karaychakra
|
||||||
|
const response = await axios({
|
||||||
|
method: 'get',
|
||||||
|
url: `https://api.wappconnect.com/api/sendText?token=${WAPP_TOKEN}&phone=${formatted}&message=${message}`,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ WhatsApp sent:', response.data);
|
||||||
|
return { success: true, data: response.data };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Status:', error?.response?.status);
|
||||||
|
console.error('❌ Body:', JSON.stringify(error?.response?.data));
|
||||||
|
console.error('❌ Real error:', error.message);
|
||||||
|
throw new Error('Failed to send WhatsApp OTP');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { generateOTP, sendOTP, TEST_OTP, TEST_PHONES };
|
||||||
34
src/utils/mailer.js
Normal file
34
src/utils/mailer.js
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
const nodemailer = require('nodemailer');
|
||||||
|
const ejs = require('ejs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const transporter = nodemailer.createTransport({
|
||||||
|
service: 'gmail',
|
||||||
|
auth: {
|
||||||
|
user: process.env.EMAIL_USER,
|
||||||
|
pass: process.env.EMAIL_PASS,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const sendEmail = async (to, subject, templateName, templateData) => {
|
||||||
|
// Render EJS template
|
||||||
|
const templatePath = path.join(__dirname, '../views/emails', `${templateName}.ejs`);
|
||||||
|
const html = await ejs.renderFile(templatePath, templateData);
|
||||||
|
|
||||||
|
const mailOptions = {
|
||||||
|
from: `"VC E-Commerce" <${process.env.EMAIL_USER}>`,
|
||||||
|
to,
|
||||||
|
subject,
|
||||||
|
html,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await transporter.sendMail(mailOptions);
|
||||||
|
console.log('Email sent to', to);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error sending email', err);
|
||||||
|
throw new Error('Failed to send email');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = sendEmail;
|
||||||
315
src/utils/paytm.js
Normal file
315
src/utils/paytm.js
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
// utils/paytm.js
|
||||||
|
const https = require('https');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paytm Configuration
|
||||||
|
* Add these to your .env file:
|
||||||
|
* PAYTM_MERCHANT_ID=your_merchant_id
|
||||||
|
* PAYTM_MERCHANT_KEY=your_merchant_key
|
||||||
|
* PAYTM_WEBSITE=WEBSTAGING (for staging) or your website name
|
||||||
|
* PAYTM_CHANNEL_ID=WEB
|
||||||
|
* PAYTM_INDUSTRY_TYPE=Retail
|
||||||
|
* PAYTM_HOST=securegw-stage.paytm.in (for staging) or securegw.paytm.in (for production)
|
||||||
|
*/
|
||||||
|
|
||||||
|
const PaytmConfig = {
|
||||||
|
mid: process.env.PAYTM_MERCHANT_ID,
|
||||||
|
key: process.env.PAYTM_MERCHANT_KEY,
|
||||||
|
website: process.env.PAYTM_WEBSITE || 'WEBSTAGING',
|
||||||
|
channelId: process.env.PAYTM_CHANNEL_ID || 'WEB',
|
||||||
|
industryType: process.env.PAYTM_INDUSTRY_TYPE || 'Retail',
|
||||||
|
host: process.env.PAYTM_HOST || 'securegw-stage.paytm.in',
|
||||||
|
callbackUrl: process.env.PAYTM_CALLBACK_URL || 'http://localhost:3000/api/payments/paytm/callback',
|
||||||
|
};
|
||||||
|
console.log(
|
||||||
|
'Merchant Key Length:',
|
||||||
|
process.env.PAYTM_MERCHANT_KEY.length
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate Paytm Checksum
|
||||||
|
*/
|
||||||
|
const generateChecksum = (params, merchantKey) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.stringify(params);
|
||||||
|
|
||||||
|
const salt = crypto.randomBytes(4).toString('hex');
|
||||||
|
const hash = crypto
|
||||||
|
.createHash('sha256')
|
||||||
|
.update(data + salt)
|
||||||
|
.digest('hex');
|
||||||
|
|
||||||
|
const checksum = hash + salt;
|
||||||
|
const encryptedChecksum = encrypt(checksum, merchantKey);
|
||||||
|
|
||||||
|
resolve(encryptedChecksum);
|
||||||
|
} catch (err) {
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify Paytm Checksum
|
||||||
|
*/
|
||||||
|
const verifyChecksum = (params, merchantKey, checksumHash) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
try {
|
||||||
|
const decrypted = decrypt(checksumHash, merchantKey);
|
||||||
|
const salt = decrypted.slice(-8);
|
||||||
|
|
||||||
|
const hash = crypto
|
||||||
|
.createHash('sha256')
|
||||||
|
.update(JSON.stringify(params) + salt)
|
||||||
|
.digest('hex');
|
||||||
|
|
||||||
|
resolve(hash + salt === decrypted);
|
||||||
|
} catch (err) {
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypt data using AES-128-CBC (Paytm standard)
|
||||||
|
*/
|
||||||
|
const encrypt = (data, key) => {
|
||||||
|
if (key.length !== 16) {
|
||||||
|
throw new Error('Paytm Merchant Key must be exactly 16 characters');
|
||||||
|
}
|
||||||
|
|
||||||
|
const iv = Buffer.from('@@@@&&&&####$$$$'); // Paytm fixed IV
|
||||||
|
const cipher = crypto.createCipheriv(
|
||||||
|
'aes-128-cbc',
|
||||||
|
Buffer.from(key, 'utf8'),
|
||||||
|
iv
|
||||||
|
);
|
||||||
|
|
||||||
|
let encrypted = cipher.update(data, 'utf8', 'base64');
|
||||||
|
encrypted += cipher.final('base64');
|
||||||
|
return encrypted;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt data using AES-128-CBC
|
||||||
|
*/
|
||||||
|
const decrypt = (encryptedData, key) => {
|
||||||
|
const iv = Buffer.from('@@@@&&&&####$$$$');
|
||||||
|
const decipher = crypto.createDecipheriv(
|
||||||
|
'aes-128-cbc',
|
||||||
|
Buffer.from(key, 'utf8'),
|
||||||
|
iv
|
||||||
|
);
|
||||||
|
|
||||||
|
let decrypted = decipher.update(encryptedData, 'base64', 'utf8');
|
||||||
|
decrypted += decipher.final('utf8');
|
||||||
|
return decrypted;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initiate Paytm Transaction
|
||||||
|
*/
|
||||||
|
const initiateTransaction = async (orderId, amount, customerId, email, mobile) => {
|
||||||
|
const paytmParams = {
|
||||||
|
body: {
|
||||||
|
requestType: 'Payment',
|
||||||
|
mid: PaytmConfig.mid,
|
||||||
|
websiteName: PaytmConfig.website,
|
||||||
|
orderId: orderId,
|
||||||
|
callbackUrl: PaytmConfig.callbackUrl,
|
||||||
|
txnAmount: {
|
||||||
|
value: amount.toString(),
|
||||||
|
currency: 'INR',
|
||||||
|
},
|
||||||
|
userInfo: {
|
||||||
|
custId: customerId,
|
||||||
|
email: email,
|
||||||
|
mobile: mobile,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const checksum = await generateChecksum(
|
||||||
|
JSON.stringify(paytmParams.body),
|
||||||
|
PaytmConfig.key
|
||||||
|
);
|
||||||
|
|
||||||
|
paytmParams.head = {
|
||||||
|
signature: checksum,
|
||||||
|
};
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const options = {
|
||||||
|
hostname: PaytmConfig.host,
|
||||||
|
port: 443,
|
||||||
|
path: `/theia/api/v1/initiateTransaction?mid=${PaytmConfig.mid}&orderId=${orderId}`,
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const req = https.request(options, (res) => {
|
||||||
|
let data = '';
|
||||||
|
|
||||||
|
res.on('data', (chunk) => {
|
||||||
|
data += chunk;
|
||||||
|
});
|
||||||
|
|
||||||
|
res.on('end', () => {
|
||||||
|
try {
|
||||||
|
const response = JSON.parse(data);
|
||||||
|
resolve({
|
||||||
|
success: true,
|
||||||
|
txnToken: response.body.txnToken,
|
||||||
|
orderId: orderId,
|
||||||
|
amount: amount,
|
||||||
|
...response,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('error', (error) => {
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
req.write(JSON.stringify(paytmParams));
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check Transaction Status
|
||||||
|
*/
|
||||||
|
const checkTransactionStatus = async (orderId) => {
|
||||||
|
const paytmParams = {
|
||||||
|
body: {
|
||||||
|
mid: PaytmConfig.mid,
|
||||||
|
orderId: orderId,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const checksum = await generateChecksum(
|
||||||
|
JSON.stringify(paytmParams.body),
|
||||||
|
PaytmConfig.key
|
||||||
|
);
|
||||||
|
|
||||||
|
paytmParams.head = {
|
||||||
|
signature: checksum,
|
||||||
|
};
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const options = {
|
||||||
|
hostname: PaytmConfig.host,
|
||||||
|
port: 443,
|
||||||
|
path: `/v3/order/status`,
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const req = https.request(options, (res) => {
|
||||||
|
let data = '';
|
||||||
|
|
||||||
|
res.on('data', (chunk) => {
|
||||||
|
data += chunk;
|
||||||
|
});
|
||||||
|
|
||||||
|
res.on('end', () => {
|
||||||
|
try {
|
||||||
|
const response = JSON.parse(data);
|
||||||
|
resolve(response);
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('error', (error) => {
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
req.write(JSON.stringify(paytmParams));
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process Refund
|
||||||
|
*/
|
||||||
|
const processRefund = async (orderId, refId, txnId, amount) => {
|
||||||
|
const paytmParams = {
|
||||||
|
body: {
|
||||||
|
mid: PaytmConfig.mid,
|
||||||
|
orderId: orderId,
|
||||||
|
refId: refId,
|
||||||
|
txnId: txnId,
|
||||||
|
txnType: 'REFUND',
|
||||||
|
refundAmount: amount.toString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const checksum = await generateChecksum(
|
||||||
|
JSON.stringify(paytmParams.body),
|
||||||
|
PaytmConfig.key
|
||||||
|
);
|
||||||
|
|
||||||
|
paytmParams.head = {
|
||||||
|
signature: checksum,
|
||||||
|
};
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const options = {
|
||||||
|
hostname: PaytmConfig.host,
|
||||||
|
port: 443,
|
||||||
|
path: `/refund/apply`,
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const req = https.request(options, (res) => {
|
||||||
|
let data = '';
|
||||||
|
|
||||||
|
res.on('data', (chunk) => {
|
||||||
|
data += chunk;
|
||||||
|
});
|
||||||
|
|
||||||
|
res.on('end', () => {
|
||||||
|
try {
|
||||||
|
const response = JSON.parse(data);
|
||||||
|
resolve(response);
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('error', (error) => {
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
req.write(JSON.stringify(paytmParams));
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
PaytmConfig,
|
||||||
|
generateChecksum,
|
||||||
|
verifyChecksum,
|
||||||
|
initiateTransaction,
|
||||||
|
checkTransactionStatus,
|
||||||
|
processRefund,
|
||||||
|
};
|
||||||
23
src/utils/uploadToS3.js
Normal file
23
src/utils/uploadToS3.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
const { PutObjectCommand } = require("@aws-sdk/client-s3");
|
||||||
|
const s3 = require("../config/s3");
|
||||||
|
const { v4: uuidv4 } = require("uuid");
|
||||||
|
|
||||||
|
const uploadToS3 = async (file, folder = "products") => {
|
||||||
|
const ext = file.originalname.split(".").pop();
|
||||||
|
const key = `${folder}/${uuidv4()}.${ext}`;
|
||||||
|
|
||||||
|
await s3.send(
|
||||||
|
new PutObjectCommand({
|
||||||
|
Bucket: process.env.AWS_S3_BUCKET,
|
||||||
|
Key: key,
|
||||||
|
Body: file.buffer,
|
||||||
|
ContentType: file.mimetype,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// return `${process.env.AWS_ENDPOINT}/${process.env.AWS_S3_BUCKET}/${key}`;
|
||||||
|
return `https://${process.env.AWS_ENDPOINT}/${process.env.AWS_S3_BUCKET}/${key}`;
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = uploadToS3;
|
||||||
35
src/utils/whatsapp.js
Normal file
35
src/utils/whatsapp.js
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
// utils/whatsapp.js
|
||||||
|
const axios = require('axios');
|
||||||
|
|
||||||
|
const sendOTP = async (phone, otp) => {
|
||||||
|
const baseUrl = process.env.WAPP_BASE_URL;
|
||||||
|
const token = process.env.WAPP_TOKEN;
|
||||||
|
const instance = process.env.WAPP_INSTANCE; // SSM
|
||||||
|
|
||||||
|
const formattedPhone = `${phone}@c.us`;
|
||||||
|
const message = `Your OTP is: *${otp}*\nValid for 5 minutes.`;
|
||||||
|
|
||||||
|
// 👇 These 3 lines will tell us exactly what's wrong
|
||||||
|
console.log('BASE_URL:', baseUrl);
|
||||||
|
console.log('TOKEN:', token ? token.substring(0, 10) + '...' : 'MISSING ❌');
|
||||||
|
console.log('INSTANCE:', instance || 'MISSING ❌');
|
||||||
|
|
||||||
|
const url = `${baseUrl}/api/${token}/${instance}/send-message`;
|
||||||
|
console.log('Calling URL:', url);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.post(url,
|
||||||
|
{ phone: formattedPhone, message },
|
||||||
|
{ headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
console.log('✅ WappConnect response:', JSON.stringify(response.data));
|
||||||
|
return response.data;
|
||||||
|
} catch (err) {
|
||||||
|
console.log('❌ Status:', err.response?.status);
|
||||||
|
console.log('❌ Response body:', JSON.stringify(err.response?.data));
|
||||||
|
console.log('❌ Full URL was:', url);
|
||||||
|
throw new Error('Failed to send WhatsApp OTP');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = { sendOTP };
|
||||||
35
src/views/emails/reset-password.ejs
Normal file
35
src/views/emails/reset-password.ejs
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Password Reset</title>
|
||||||
|
</head>
|
||||||
|
<body style="margin:0; padding:0; font-family: Arial, sans-serif; background-color: #f4f4f4;">
|
||||||
|
<table align="center" width="100%" cellpadding="0" cellspacing="0" style="max-width:600px; margin:20px auto; background-color:#ffffff; border-radius:8px; overflow:hidden; box-shadow:0 4px 8px rgba(0,0,0,0.1);">
|
||||||
|
<tr>
|
||||||
|
<td style="background-color:#4CAF50; padding:20px; text-align:center; color:#ffffff; font-size:24px; font-weight:bold;">
|
||||||
|
VC E-Commerce
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:30px; text-align:left; color:#333333;">
|
||||||
|
<h2 style="color:#4CAF50;">Password Reset Request</h2>
|
||||||
|
<p>Hi <%= firstName %>,</p>
|
||||||
|
<p>You recently requested to reset your password. Click the button below to reset it. This link is valid for 1 hour only.</p>
|
||||||
|
<p style="text-align:center; margin:30px 0;">
|
||||||
|
<a href="<%= resetUrl %>" style="background-color:#4CAF50; color:#ffffff; text-decoration:none; padding:15px 25px; border-radius:5px; display:inline-block; font-weight:bold;">
|
||||||
|
Reset Password
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p>If you did not request a password reset, please ignore this email.</p>
|
||||||
|
<p>Thanks,<br>The VC E-Commerce Team</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="background-color:#f4f4f4; padding:20px; text-align:center; color:#777777; font-size:12px;">
|
||||||
|
© <%= new Date().getFullYear() %> VC E-Commerce. All rights reserved.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
35
src/views/emails/verify-email.ejs
Normal file
35
src/views/emails/verify-email.ejs
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Email Verification</title>
|
||||||
|
</head>
|
||||||
|
<body style="margin:0; padding:0; font-family: Arial, sans-serif; background-color: #f4f4f4;">
|
||||||
|
<table align="center" width="100%" cellpadding="0" cellspacing="0" style="max-width:600px; margin:20px auto; background-color:#ffffff; border-radius:8px; overflow:hidden; box-shadow:0 4px 8px rgba(0,0,0,0.1);">
|
||||||
|
<tr>
|
||||||
|
<td style="background-color:#4CAF50; padding:20px; text-align:center; color:#ffffff; font-size:24px; font-weight:bold;">
|
||||||
|
VC E-Commerce
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:30px; text-align:left; color:#333333;">
|
||||||
|
<h2 style="color:#4CAF50;">Email Verification</h2>
|
||||||
|
<p>Hi <%= firstName %>,</p>
|
||||||
|
<p>Thank you for registering! Please click the button below to verify your email address and activate your account.</p>
|
||||||
|
<p style="text-align:center; margin:30px 0;">
|
||||||
|
<a href="<%= verificationUrl %>" style="background-color:#4CAF50; color:#ffffff; text-decoration:none; padding:15px 25px; border-radius:5px; display:inline-block; font-weight:bold;">
|
||||||
|
Verify Email
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p>If you did not register, you can safely ignore this email.</p>
|
||||||
|
<p>Thanks,<br>The VC E-Commerce Team</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="background-color:#f4f4f4; padding:20px; text-align:center; color:#777777; font-size:12px;">
|
||||||
|
© <%= new Date().getFullYear() %> VC E-Commerce. All rights reserved.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
BIN
structure.txt
Normal file
BIN
structure.txt
Normal file
Binary file not shown.
119
test-connections.js
Normal file
119
test-connections.js
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Database Connection Test Script
|
||||||
|
*
|
||||||
|
* This script tests the database connections to help troubleshoot setup issues.
|
||||||
|
*/
|
||||||
|
|
||||||
|
require('dotenv').config();
|
||||||
|
|
||||||
|
const { PrismaClient } = require('@prisma/client');
|
||||||
|
const mongoose = require('mongoose');
|
||||||
|
|
||||||
|
async function testPostgreSQL() {
|
||||||
|
console.log('🐘 Testing PostgreSQL connection...');
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prisma.$connect();
|
||||||
|
console.log('✅ PostgreSQL connection successful!');
|
||||||
|
|
||||||
|
// Test a simple query
|
||||||
|
const result = await prisma.$queryRaw`SELECT version()`;
|
||||||
|
console.log('📊 PostgreSQL version:', result[0]?.version || 'Unknown');
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.log('❌ PostgreSQL connection failed:', error.message);
|
||||||
|
console.log('💡 Check your DATABASE_URL in .env file');
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testMongoDB() {
|
||||||
|
console.log('\n🍃 Testing MongoDB connection...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await mongoose.connect(process.env.MONGODB_URI, {
|
||||||
|
useNewUrlParser: true,
|
||||||
|
useUnifiedTopology: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ MongoDB connection successful!');
|
||||||
|
|
||||||
|
// Test a simple operation
|
||||||
|
const adminDb = mongoose.connection.db.admin();
|
||||||
|
const buildInfo = await adminDb.buildInfo();
|
||||||
|
console.log('📊 MongoDB version:', buildInfo.version);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.log('❌ MongoDB connection failed:', error.message);
|
||||||
|
console.log('💡 Check your MONGODB_URI in .env file');
|
||||||
|
console.log('💡 Make sure MongoDB is running on port 27017');
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
await mongoose.connection.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testRedis() {
|
||||||
|
console.log('\n🔴 Testing Redis connection...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const Redis = require('ioredis');
|
||||||
|
const redis = new Redis(process.env.REDIS_URL);
|
||||||
|
|
||||||
|
await redis.ping();
|
||||||
|
console.log('✅ Redis connection successful!');
|
||||||
|
|
||||||
|
const info = await redis.info('server');
|
||||||
|
const version = info.match(/redis_version:([^\r\n]+)/);
|
||||||
|
if (version) {
|
||||||
|
console.log('📊 Redis version:', version[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
redis.disconnect();
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.log('❌ Redis connection failed:', error.message);
|
||||||
|
console.log('💡 Check your REDIS_URL in .env file');
|
||||||
|
console.log('💡 Make sure Redis is running on port 6379');
|
||||||
|
console.log('💡 You can comment out REDIS_URL if you don\'t need Redis');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('🔍 Testing database connections...\n');
|
||||||
|
|
||||||
|
const results = {
|
||||||
|
postgresql: await testPostgreSQL(),
|
||||||
|
mongodb: await testMongoDB(),
|
||||||
|
redis: await testRedis(),
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('\n📋 Connection Summary:');
|
||||||
|
console.log(`PostgreSQL: ${results.postgresql ? '✅ Connected' : '❌ Failed'}`);
|
||||||
|
console.log(`MongoDB: ${results.mongodb ? '✅ Connected' : '❌ Failed'}`);
|
||||||
|
console.log(`Redis: ${results.redis ? '✅ Connected' : '❌ Failed'}`);
|
||||||
|
|
||||||
|
if (results.postgresql && results.mongodb) {
|
||||||
|
console.log('\n🎉 Core databases are connected! You can now run:');
|
||||||
|
console.log(' npm run db:push # Create database schema');
|
||||||
|
console.log(' npm run db:seed # Seed with sample data');
|
||||||
|
console.log(' npm run dev # Start development server');
|
||||||
|
} else {
|
||||||
|
console.log('\n⚠️ Some databases failed to connect. Please check your configuration.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!results.redis) {
|
||||||
|
console.log('\n💡 Redis is optional. The app will work without it, but caching and background jobs won\'t be available.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(console.error);
|
||||||
191
testWhatsApp.js
Normal file
191
testWhatsApp.js
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
// testWappConnect.js - COMPREHENSIVE TEST
|
||||||
|
|
||||||
|
require('dotenv').config();
|
||||||
|
const axios = require('axios');
|
||||||
|
|
||||||
|
const BASE_URL = 'https://api.wappconnect.com';
|
||||||
|
const SESSION = 'Sahasrara_metatech';
|
||||||
|
const USERNAME = '8369805801';
|
||||||
|
const PASSWORD = '918369805801';
|
||||||
|
const TEST_PHONE = '918070905353';
|
||||||
|
|
||||||
|
async function comprehensiveTest() {
|
||||||
|
console.log('🧪 WappConnect Comprehensive Test\n');
|
||||||
|
console.log('📋 Configuration:');
|
||||||
|
console.log(' Base URL:', BASE_URL);
|
||||||
|
console.log(' Session:', SESSION);
|
||||||
|
console.log(' Username:', USERNAME);
|
||||||
|
console.log(' Test Phone:', TEST_PHONE);
|
||||||
|
console.log('\n' + '='.repeat(60) + '\n');
|
||||||
|
|
||||||
|
// ===== TEST 1: Login =====
|
||||||
|
console.log('TEST 1: Login to WappConnect\n');
|
||||||
|
|
||||||
|
let authToken = null;
|
||||||
|
let cookies = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const loginResponse = await axios.post(
|
||||||
|
`${BASE_URL}/api/auth/login`,
|
||||||
|
{
|
||||||
|
username: USERNAME,
|
||||||
|
password: PASSWORD,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('✅ Login successful!');
|
||||||
|
console.log(' Response:', loginResponse.data);
|
||||||
|
|
||||||
|
if (loginResponse.data.token) {
|
||||||
|
authToken = loginResponse.data.token;
|
||||||
|
console.log(' Token:', authToken.substring(0, 20) + '...');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loginResponse.headers['set-cookie']) {
|
||||||
|
cookies = loginResponse.headers['set-cookie'].join('; ');
|
||||||
|
console.log(' Cookies received');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Login failed:', error.message);
|
||||||
|
if (error.response) {
|
||||||
|
console.error(' Status:', error.response.status);
|
||||||
|
console.error(' Data:', error.response.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n' + '='.repeat(60) + '\n');
|
||||||
|
|
||||||
|
// ===== TEST 2: Check Session Status =====
|
||||||
|
console.log('TEST 2: Check Session Status\n');
|
||||||
|
|
||||||
|
const statusEndpoints = [
|
||||||
|
`/api/${SESSION}/status`,
|
||||||
|
`/api/${SESSION}/check-connection`,
|
||||||
|
`/api/${SESSION}/check-connection-session`,
|
||||||
|
`/${SESSION}/status`,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const endpoint of statusEndpoints) {
|
||||||
|
try {
|
||||||
|
console.log(`🔄 Trying: ${endpoint}`);
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (authToken) headers['Authorization'] = `Bearer ${authToken}`;
|
||||||
|
if (cookies) headers['Cookie'] = cookies;
|
||||||
|
|
||||||
|
const response = await axios.get(`${BASE_URL}${endpoint}`, { headers });
|
||||||
|
|
||||||
|
console.log('✅ SUCCESS!');
|
||||||
|
console.log(' Status:', response.data);
|
||||||
|
break;
|
||||||
|
} catch (error) {
|
||||||
|
console.log(` ❌ Failed: ${error.response?.status || error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n' + '='.repeat(60) + '\n');
|
||||||
|
|
||||||
|
// ===== TEST 3: Send Message =====
|
||||||
|
console.log('TEST 3: Send Test Message\n');
|
||||||
|
|
||||||
|
const messageEndpoints = [
|
||||||
|
`/api/${SESSION}/send-text`,
|
||||||
|
`/api/${SESSION}/sendText`,
|
||||||
|
`/api/${SESSION}/send-message`,
|
||||||
|
`/api/${SESSION}/sendMessage`,
|
||||||
|
`/${SESSION}/send-text`,
|
||||||
|
`/send-message/${SESSION}`,
|
||||||
|
`/api/sendText/${SESSION}`,
|
||||||
|
];
|
||||||
|
|
||||||
|
const messagePayloads = [
|
||||||
|
// Payload variation 1
|
||||||
|
{
|
||||||
|
phone: TEST_PHONE,
|
||||||
|
message: '🧪 Test from WappConnect - Method 1',
|
||||||
|
isGroup: false,
|
||||||
|
},
|
||||||
|
// Payload variation 2
|
||||||
|
{
|
||||||
|
phone: TEST_PHONE,
|
||||||
|
text: '🧪 Test from WappConnect - Method 2',
|
||||||
|
},
|
||||||
|
// Payload variation 3
|
||||||
|
{
|
||||||
|
chatId: `${TEST_PHONE}@c.us`,
|
||||||
|
message: '🧪 Test from WappConnect - Method 3',
|
||||||
|
},
|
||||||
|
// Payload variation 4
|
||||||
|
{
|
||||||
|
number: TEST_PHONE,
|
||||||
|
body: '🧪 Test from WappConnect - Method 4',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
let messageSent = false;
|
||||||
|
|
||||||
|
for (const endpoint of messageEndpoints) {
|
||||||
|
if (messageSent) break;
|
||||||
|
|
||||||
|
for (const payload of messagePayloads) {
|
||||||
|
try {
|
||||||
|
console.log(`🔄 Trying: ${endpoint}`);
|
||||||
|
console.log(` Payload:`, JSON.stringify(payload, null, 2));
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (authToken) headers['Authorization'] = `Bearer ${authToken}`;
|
||||||
|
if (cookies) headers['Cookie'] = cookies;
|
||||||
|
|
||||||
|
const response = await axios.post(
|
||||||
|
`${BASE_URL}${endpoint}`,
|
||||||
|
payload,
|
||||||
|
{ headers, timeout: 15000 }
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('✅ MESSAGE SENT SUCCESSFULLY!');
|
||||||
|
console.log(' Response:', response.data);
|
||||||
|
console.log('\n📱 CHECK YOUR WHATSAPP NOW!\n');
|
||||||
|
|
||||||
|
messageSent = true;
|
||||||
|
|
||||||
|
// Save working configuration
|
||||||
|
console.log('💾 WORKING CONFIGURATION:');
|
||||||
|
console.log(' Endpoint:', endpoint);
|
||||||
|
console.log(' Payload:', JSON.stringify(payload, null, 2));
|
||||||
|
if (authToken) console.log(' Uses Token: Yes');
|
||||||
|
if (cookies) console.log(' Uses Cookies: Yes');
|
||||||
|
|
||||||
|
break;
|
||||||
|
} catch (error) {
|
||||||
|
console.log(` ❌ Failed: ${error.response?.status || error.message}`);
|
||||||
|
if (error.response?.data) {
|
||||||
|
console.log(` Error:`, error.response.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!messageSent) {
|
||||||
|
console.log('\n❌ Could not send message with any endpoint/payload combination');
|
||||||
|
console.log('\nℹ️ Recommendations:');
|
||||||
|
console.log(' 1. Check if WhatsApp is connected in dashboard');
|
||||||
|
console.log(' 2. Try connecting WhatsApp again (scan QR)');
|
||||||
|
console.log(' 3. Contact WappConnect support');
|
||||||
|
console.log(' 4. Consider using Twilio instead (easier setup)');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n' + '='.repeat(60));
|
||||||
|
}
|
||||||
|
|
||||||
|
comprehensiveTest();
|
||||||
Reference in New Issue
Block a user