initial commit
This commit is contained in:
commit
57d8f3fd5c
50
.env.example
Normal file
50
.env.example
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
# Server Configuration
|
||||||
|
NODE_ENV=development
|
||||||
|
PORT=3000
|
||||||
|
HOST=localhost
|
||||||
|
|
||||||
|
# Database Configuration
|
||||||
|
DB_HOST=localhost
|
||||||
|
DB_PORT=5432
|
||||||
|
DB_NAME=cloudtopiaa_reseller_portal
|
||||||
|
DB_USERNAME=postgres
|
||||||
|
DB_PASSWORD=your_password
|
||||||
|
DB_DIALECT=postgres
|
||||||
|
|
||||||
|
# JWT Configuration
|
||||||
|
JWT_SECRET=your_super_secret_jwt_key_here
|
||||||
|
JWT_REFRESH_SECRET=your_super_secret_refresh_key_here
|
||||||
|
JWT_EXPIRES_IN=15m
|
||||||
|
JWT_REFRESH_EXPIRES_IN=7d
|
||||||
|
|
||||||
|
# Redis Configuration
|
||||||
|
REDIS_HOST=localhost
|
||||||
|
REDIS_PORT=6379
|
||||||
|
REDIS_PASSWORD=
|
||||||
|
REDIS_DB=0
|
||||||
|
|
||||||
|
# Email Configuration
|
||||||
|
SMTP_HOST=smtp.gmail.com
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_SECURE=false
|
||||||
|
SMTP_USER=your_email@gmail.com
|
||||||
|
SMTP_PASS=your_app_password
|
||||||
|
FROM_EMAIL=noreply@cloudtopiaa.com
|
||||||
|
FROM_NAME=Cloudtopiaa Reseller Portal
|
||||||
|
|
||||||
|
# File Upload Configuration
|
||||||
|
UPLOAD_PATH=uploads/
|
||||||
|
MAX_FILE_SIZE=5242880
|
||||||
|
ALLOWED_FILE_TYPES=pdf,doc,docx,jpg,jpeg,png
|
||||||
|
|
||||||
|
# Security Configuration
|
||||||
|
BCRYPT_ROUNDS=12
|
||||||
|
OTP_EXPIRES_IN=300000
|
||||||
|
SESSION_SECRET=your_session_secret_here
|
||||||
|
|
||||||
|
# Rate Limiting
|
||||||
|
RATE_LIMIT_WINDOW_MS=900000
|
||||||
|
RATE_LIMIT_MAX_REQUESTS=100
|
||||||
|
|
||||||
|
# Frontend URL (for CORS and email links)
|
||||||
|
FRONTEND_URL=http://localhost:3000
|
||||||
101
.gitignore
vendored
Normal file
101
.gitignore
vendored
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
# Dependency directories
|
||||||
|
node_modules/
|
||||||
|
jspm_packages/
|
||||||
|
|
||||||
|
# Optional npm cache directory
|
||||||
|
.npm
|
||||||
|
|
||||||
|
# Optional eslint cache
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
# 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 (https://parceljs.org/)
|
||||||
|
.cache
|
||||||
|
.parcel-cache
|
||||||
|
|
||||||
|
# next.js build output
|
||||||
|
.next
|
||||||
|
|
||||||
|
# nuxt.js build output
|
||||||
|
.nuxt
|
||||||
|
|
||||||
|
# vuepress build output
|
||||||
|
.vuepress/dist
|
||||||
|
|
||||||
|
# Serverless directories
|
||||||
|
.serverless/
|
||||||
|
|
||||||
|
# FuseBox cache
|
||||||
|
.fusebox/
|
||||||
|
|
||||||
|
# DynamoDB Local files
|
||||||
|
.dynamodb/
|
||||||
|
|
||||||
|
# TernJS port file
|
||||||
|
.tern-port
|
||||||
|
|
||||||
|
# Stores VSCode versions used for testing VSCode extensions
|
||||||
|
.vscode-test
|
||||||
|
|
||||||
|
# Uploads directory
|
||||||
|
uploads/
|
||||||
|
temp/
|
||||||
|
|
||||||
|
# Database
|
||||||
|
*.sqlite
|
||||||
|
*.db
|
||||||
|
|
||||||
|
# OS generated files
|
||||||
|
.DS_Store
|
||||||
|
.DS_Store?
|
||||||
|
._*
|
||||||
|
.Spotlight-V100
|
||||||
|
.Trashes
|
||||||
|
ehthumbs.db
|
||||||
|
Thumbs.db
|
||||||
8
.sequelizerc
Normal file
8
.sequelizerc
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
'config': path.resolve('src/config', 'database.js'),
|
||||||
|
'models-path': path.resolve('src/models'),
|
||||||
|
'seeders-path': path.resolve('src/seeders'),
|
||||||
|
'migrations-path': path.resolve('src/migrations')
|
||||||
|
};
|
||||||
59
package.json
Normal file
59
package.json
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
{
|
||||||
|
"name": "cloudtopiaa-reseller-portal",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Cloudtopiaa Reseller Portal - A centralized web-based platform for resellers to manage cloud services",
|
||||||
|
"main": "src/app.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node src/app.js",
|
||||||
|
"dev": "nodemon src/app.js",
|
||||||
|
"test": "jest",
|
||||||
|
"migrate": "sequelize-cli db:migrate",
|
||||||
|
"migrate:undo": "sequelize-cli db:migrate:undo",
|
||||||
|
"seed": "sequelize-cli db:seed:all",
|
||||||
|
"seed:undo": "sequelize-cli db:seed:undo:all",
|
||||||
|
"db:create": "sequelize-cli db:create",
|
||||||
|
"db:drop": "sequelize-cli db:drop"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"reseller",
|
||||||
|
"portal",
|
||||||
|
"cloud",
|
||||||
|
"express",
|
||||||
|
"nodejs",
|
||||||
|
"postgresql"
|
||||||
|
],
|
||||||
|
"author": "Cloudtopiaa Team",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"express-rate-limit": "^6.7.0",
|
||||||
|
"express-validator": "^6.15.0",
|
||||||
|
"helmet": "^6.1.5",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.0.3",
|
||||||
|
"sequelize": "^6.31.1",
|
||||||
|
"pg": "^8.11.0",
|
||||||
|
"pg-hstore": "^2.3.4",
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
|
"jsonwebtoken": "^9.0.0",
|
||||||
|
"redis": "^4.6.7",
|
||||||
|
"nodemailer": "^6.9.2",
|
||||||
|
"speakeasy": "^2.0.0",
|
||||||
|
"qrcode": "^1.5.3",
|
||||||
|
"multer": "^1.4.5-lts.1",
|
||||||
|
"uuid": "^9.0.0",
|
||||||
|
"joi": "^17.9.2",
|
||||||
|
"morgan": "^1.10.0",
|
||||||
|
"compression": "^1.7.4",
|
||||||
|
"cookie-parser": "^1.4.6"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"nodemon": "^2.0.22",
|
||||||
|
"jest": "^29.5.0",
|
||||||
|
"supertest": "^6.3.3",
|
||||||
|
"sequelize-cli": "^6.6.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
183
src/app.js
Normal file
183
src/app.js
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const cors = require('cors');
|
||||||
|
const helmet = require('helmet');
|
||||||
|
const compression = require('compression');
|
||||||
|
const cookieParser = require('cookie-parser');
|
||||||
|
const morgan = require('morgan');
|
||||||
|
const rateLimit = require('express-rate-limit');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
// Import configurations
|
||||||
|
require('dotenv').config();
|
||||||
|
const { sequelize } = require('./models');
|
||||||
|
const redisClient = require('./config/redis');
|
||||||
|
|
||||||
|
// Import routes
|
||||||
|
const authRoutes = require('./routes/auth');
|
||||||
|
const userRoutes = require('./routes/users');
|
||||||
|
const resellerRoutes = require('./routes/resellers');
|
||||||
|
const productRoutes = require('./routes/products');
|
||||||
|
const customerRoutes = require('./routes/customers');
|
||||||
|
const billingRoutes = require('./routes/billing');
|
||||||
|
const dashboardRoutes = require('./routes/dashboard');
|
||||||
|
const reportsRoutes = require('./routes/reports');
|
||||||
|
const adminRoutes = require('./routes/admin');
|
||||||
|
const ordersRoutes = require('./routes/orders');
|
||||||
|
const provisioningRoutes = require('./routes/provisioning');
|
||||||
|
const legalRoutes = require('./routes/legal');
|
||||||
|
|
||||||
|
// Import middleware
|
||||||
|
const errorHandler = require('./middleware/errorHandler');
|
||||||
|
const { authenticateToken } = require('./middleware/auth');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
// Security middleware
|
||||||
|
app.use(helmet({
|
||||||
|
contentSecurityPolicy: {
|
||||||
|
directives: {
|
||||||
|
defaultSrc: ["'self'"],
|
||||||
|
styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"],
|
||||||
|
fontSrc: ["'self'", "https://fonts.gstatic.com"],
|
||||||
|
imgSrc: ["'self'", "data:", "https:"],
|
||||||
|
scriptSrc: ["'self'"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// CORS configuration
|
||||||
|
app.use(cors({
|
||||||
|
origin: process.env.FRONTEND_URL || 'http://localhost:3000',
|
||||||
|
credentials: true,
|
||||||
|
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||||
|
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With']
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Rate limiting
|
||||||
|
const limiter = rateLimit({
|
||||||
|
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS) || 15 * 60 * 1000, // 15 minutes
|
||||||
|
max: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS) || 100,
|
||||||
|
message: {
|
||||||
|
error: 'Too many requests from this IP, please try again later.'
|
||||||
|
},
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false
|
||||||
|
});
|
||||||
|
app.use(limiter);
|
||||||
|
|
||||||
|
// Body parsing middleware
|
||||||
|
app.use(compression());
|
||||||
|
app.use(express.json({ limit: '10mb' }));
|
||||||
|
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
|
||||||
|
app.use(cookieParser());
|
||||||
|
|
||||||
|
// Logging
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
app.use(morgan('dev'));
|
||||||
|
} else {
|
||||||
|
app.use(morgan('combined'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Static files
|
||||||
|
app.use('/uploads', express.static(path.join(__dirname, '../uploads')));
|
||||||
|
app.use(express.static(path.join(__dirname, '../public')));
|
||||||
|
|
||||||
|
// Health check endpoint
|
||||||
|
app.get('/health', async (req, res) => {
|
||||||
|
try {
|
||||||
|
await sequelize.authenticate();
|
||||||
|
await redisClient.ping();
|
||||||
|
res.status(200).json({
|
||||||
|
status: 'OK',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
database: 'Connected',
|
||||||
|
redis: 'Connected'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(503).json({
|
||||||
|
status: 'Service Unavailable',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// API routes
|
||||||
|
app.use('/api/auth', authRoutes);
|
||||||
|
app.use('/api/users', authenticateToken, userRoutes);
|
||||||
|
app.use('/api/resellers', authenticateToken, resellerRoutes);
|
||||||
|
app.use('/api/products', authenticateToken, productRoutes);
|
||||||
|
app.use('/api/customers', authenticateToken, customerRoutes);
|
||||||
|
app.use('/api/billing', authenticateToken, billingRoutes);
|
||||||
|
app.use('/api/dashboard', authenticateToken, dashboardRoutes);
|
||||||
|
app.use('/api/reports', authenticateToken, reportsRoutes);
|
||||||
|
app.use('/api/admin', authenticateToken, adminRoutes);
|
||||||
|
app.use('/api/orders', authenticateToken, ordersRoutes);
|
||||||
|
app.use('/api/provisioning', authenticateToken, provisioningRoutes);
|
||||||
|
app.use('/api/legal', legalRoutes); // Legal routes have their own auth middleware
|
||||||
|
|
||||||
|
// Serve frontend for SPA
|
||||||
|
app.get('*', (req, res) => {
|
||||||
|
res.sendFile(path.join(__dirname, '../public/index.html'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Error handling middleware
|
||||||
|
app.use(errorHandler);
|
||||||
|
|
||||||
|
// Database connection and server startup
|
||||||
|
const PORT = process.env.PORT || 3000;
|
||||||
|
const HOST = process.env.HOST || 'localhost';
|
||||||
|
|
||||||
|
async function startServer() {
|
||||||
|
try {
|
||||||
|
// Test database connection
|
||||||
|
await sequelize.authenticate();
|
||||||
|
console.log('✅ Database connection established successfully.');
|
||||||
|
|
||||||
|
// Test Redis connection
|
||||||
|
await redisClient.ping();
|
||||||
|
console.log('✅ Redis connection established successfully.');
|
||||||
|
|
||||||
|
// Sync database (only in development)
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
await sequelize.sync({ alter: true });
|
||||||
|
console.log('✅ Database synchronized successfully.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start server
|
||||||
|
app.listen(PORT, HOST, () => {
|
||||||
|
console.log(`🚀 Server running on http://${HOST}:${PORT}`);
|
||||||
|
console.log(`📊 Environment: ${process.env.NODE_ENV}`);
|
||||||
|
console.log(`🔗 Health check: http://${HOST}:${PORT}/health`);
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Unable to start server:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Graceful shutdown
|
||||||
|
process.on('SIGTERM', async () => {
|
||||||
|
console.log('🔄 SIGTERM received, shutting down gracefully...');
|
||||||
|
await sequelize.close();
|
||||||
|
await redisClient.quit();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('SIGINT', async () => {
|
||||||
|
console.log('🔄 SIGINT received, shutting down gracefully...');
|
||||||
|
await sequelize.close();
|
||||||
|
await redisClient.quit();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle unhandled promise rejections
|
||||||
|
process.on('unhandledRejection', (err) => {
|
||||||
|
console.error('❌ Unhandled Promise Rejection:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
startServer();
|
||||||
|
|
||||||
|
module.exports = app;
|
||||||
70
src/config/database.js
Normal file
70
src/config/database.js
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
require('dotenv').config();
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
development: {
|
||||||
|
username: process.env.DB_USERNAME || 'postgres',
|
||||||
|
password: process.env.DB_PASSWORD || 'password',
|
||||||
|
database: process.env.DB_NAME || 'cloudtopiaa_reseller_portal_dev',
|
||||||
|
host: process.env.DB_HOST || 'localhost',
|
||||||
|
port: process.env.DB_PORT || 5432,
|
||||||
|
dialect: 'postgres',
|
||||||
|
logging: console.log,
|
||||||
|
pool: {
|
||||||
|
max: 5,
|
||||||
|
min: 0,
|
||||||
|
acquire: 30000,
|
||||||
|
idle: 10000
|
||||||
|
},
|
||||||
|
define: {
|
||||||
|
timestamps: true,
|
||||||
|
underscored: true,
|
||||||
|
paranoid: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
username: process.env.DB_USERNAME || 'postgres',
|
||||||
|
password: process.env.DB_PASSWORD || 'password',
|
||||||
|
database: process.env.DB_NAME + '_test' || 'cloudtopiaa_reseller_portal_test',
|
||||||
|
host: process.env.DB_HOST || 'localhost',
|
||||||
|
port: process.env.DB_PORT || 5432,
|
||||||
|
dialect: 'postgres',
|
||||||
|
logging: false,
|
||||||
|
pool: {
|
||||||
|
max: 5,
|
||||||
|
min: 0,
|
||||||
|
acquire: 30000,
|
||||||
|
idle: 10000
|
||||||
|
},
|
||||||
|
define: {
|
||||||
|
timestamps: true,
|
||||||
|
underscored: true,
|
||||||
|
paranoid: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
production: {
|
||||||
|
username: process.env.DB_USERNAME,
|
||||||
|
password: process.env.DB_PASSWORD,
|
||||||
|
database: process.env.DB_NAME,
|
||||||
|
host: process.env.DB_HOST,
|
||||||
|
port: process.env.DB_PORT || 5432,
|
||||||
|
dialect: 'postgres',
|
||||||
|
logging: false,
|
||||||
|
pool: {
|
||||||
|
max: 20,
|
||||||
|
min: 5,
|
||||||
|
acquire: 60000,
|
||||||
|
idle: 10000
|
||||||
|
},
|
||||||
|
define: {
|
||||||
|
timestamps: true,
|
||||||
|
underscored: true,
|
||||||
|
paranoid: true
|
||||||
|
},
|
||||||
|
dialectOptions: {
|
||||||
|
ssl: process.env.DB_SSL === 'true' ? {
|
||||||
|
require: true,
|
||||||
|
rejectUnauthorized: false
|
||||||
|
} : false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
112
src/config/email.js
Normal file
112
src/config/email.js
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
const nodemailer = require('nodemailer');
|
||||||
|
|
||||||
|
// Create transporter
|
||||||
|
const createTransporter = () => {
|
||||||
|
return nodemailer.createTransporter({
|
||||||
|
host: process.env.SMTP_HOST,
|
||||||
|
port: process.env.SMTP_PORT,
|
||||||
|
secure: process.env.SMTP_SECURE === 'true',
|
||||||
|
auth: {
|
||||||
|
user: process.env.SMTP_USER,
|
||||||
|
pass: process.env.SMTP_PASS
|
||||||
|
},
|
||||||
|
tls: {
|
||||||
|
rejectUnauthorized: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Email templates
|
||||||
|
const emailTemplates = {
|
||||||
|
welcome: (name, loginUrl) => ({
|
||||||
|
subject: 'Welcome to Cloudtopiaa Reseller Portal',
|
||||||
|
html: `
|
||||||
|
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||||
|
<h2 style="color: #2c3e50;">Welcome to Cloudtopiaa Reseller Portal</h2>
|
||||||
|
<p>Hello ${name},</p>
|
||||||
|
<p>Welcome to the Cloudtopiaa Reseller Portal! Your account has been successfully created.</p>
|
||||||
|
<p>You can now access your dashboard and start managing your cloud services.</p>
|
||||||
|
<a href="${loginUrl}" style="background-color: #3498db; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px; display: inline-block;">Login to Portal</a>
|
||||||
|
<p>If you have any questions, please don't hesitate to contact our support team.</p>
|
||||||
|
<p>Best regards,<br>Cloudtopiaa Team</p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}),
|
||||||
|
|
||||||
|
passwordReset: (name, resetUrl, otp) => ({
|
||||||
|
subject: 'Password Reset Request - Cloudtopiaa Reseller Portal',
|
||||||
|
html: `
|
||||||
|
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||||
|
<h2 style="color: #e74c3c;">Password Reset Request</h2>
|
||||||
|
<p>Hello ${name},</p>
|
||||||
|
<p>We received a request to reset your password for your Cloudtopiaa Reseller Portal account.</p>
|
||||||
|
<p>Your OTP code is: <strong style="font-size: 24px; color: #2c3e50;">${otp}</strong></p>
|
||||||
|
<p>This code will expire in 5 minutes.</p>
|
||||||
|
<p>Alternatively, you can click the link below to reset your password:</p>
|
||||||
|
<a href="${resetUrl}" style="background-color: #e74c3c; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px; display: inline-block;">Reset Password</a>
|
||||||
|
<p>If you didn't request this password reset, please ignore this email.</p>
|
||||||
|
<p>Best regards,<br>Cloudtopiaa Team</p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}),
|
||||||
|
|
||||||
|
emailVerification: (name, verificationUrl, otp) => ({
|
||||||
|
subject: 'Email Verification - Cloudtopiaa Reseller Portal',
|
||||||
|
html: `
|
||||||
|
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||||
|
<h2 style="color: #27ae60;">Email Verification</h2>
|
||||||
|
<p>Hello ${name},</p>
|
||||||
|
<p>Please verify your email address to complete your registration.</p>
|
||||||
|
<p>Your verification code is: <strong style="font-size: 24px; color: #2c3e50;">${otp}</strong></p>
|
||||||
|
<p>This code will expire in 5 minutes.</p>
|
||||||
|
<p>Alternatively, you can click the link below to verify your email:</p>
|
||||||
|
<a href="${verificationUrl}" style="background-color: #27ae60; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px; display: inline-block;">Verify Email</a>
|
||||||
|
<p>Best regards,<br>Cloudtopiaa Team</p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}),
|
||||||
|
|
||||||
|
mfaSetup: (name, qrCodeUrl, backupCodes) => ({
|
||||||
|
subject: 'Two-Factor Authentication Setup - Cloudtopiaa Reseller Portal',
|
||||||
|
html: `
|
||||||
|
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||||
|
<h2 style="color: #9b59b6;">Two-Factor Authentication Setup</h2>
|
||||||
|
<p>Hello ${name},</p>
|
||||||
|
<p>You have successfully enabled two-factor authentication for your account.</p>
|
||||||
|
<p>Please save these backup codes in a secure location:</p>
|
||||||
|
<div style="background-color: #f8f9fa; padding: 15px; border-radius: 5px; margin: 15px 0;">
|
||||||
|
${backupCodes.map(code => `<p style="margin: 5px 0; font-family: monospace;">${code}</p>`).join('')}
|
||||||
|
</div>
|
||||||
|
<p>These codes can be used to access your account if you lose access to your authenticator app.</p>
|
||||||
|
<p>Best regards,<br>Cloudtopiaa Team</p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
// Send email function
|
||||||
|
const sendEmail = async (to, template, data = {}) => {
|
||||||
|
try {
|
||||||
|
const transporter = createTransporter();
|
||||||
|
const emailContent = emailTemplates[template](data.name, data.url, data.otp, data.backupCodes);
|
||||||
|
|
||||||
|
const mailOptions = {
|
||||||
|
from: `${process.env.FROM_NAME} <${process.env.FROM_EMAIL}>`,
|
||||||
|
to: to,
|
||||||
|
subject: emailContent.subject,
|
||||||
|
html: emailContent.html
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await transporter.sendMail(mailOptions);
|
||||||
|
console.log('✅ Email sent successfully:', result.messageId);
|
||||||
|
return { success: true, messageId: result.messageId };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Email sending failed:', error);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
sendEmail,
|
||||||
|
emailTemplates
|
||||||
|
};
|
||||||
51
src/config/redis.js
Normal file
51
src/config/redis.js
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
const redis = require('redis');
|
||||||
|
|
||||||
|
const redisClient = redis.createClient({
|
||||||
|
host: process.env.REDIS_HOST || 'localhost',
|
||||||
|
port: process.env.REDIS_PORT || 6379,
|
||||||
|
password: process.env.REDIS_PASSWORD || undefined,
|
||||||
|
db: process.env.REDIS_DB || 0,
|
||||||
|
retry_strategy: (options) => {
|
||||||
|
if (options.error && options.error.code === 'ECONNREFUSED') {
|
||||||
|
console.error('❌ Redis server connection refused');
|
||||||
|
return new Error('Redis server connection refused');
|
||||||
|
}
|
||||||
|
if (options.total_retry_time > 1000 * 60 * 60) {
|
||||||
|
console.error('❌ Redis retry time exhausted');
|
||||||
|
return new Error('Retry time exhausted');
|
||||||
|
}
|
||||||
|
if (options.attempt > 10) {
|
||||||
|
console.error('❌ Redis connection attempts exceeded');
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
// Reconnect after
|
||||||
|
return Math.min(options.attempt * 100, 3000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
redisClient.on('connect', () => {
|
||||||
|
console.log('🔗 Redis client connected');
|
||||||
|
});
|
||||||
|
|
||||||
|
redisClient.on('ready', () => {
|
||||||
|
console.log('✅ Redis client ready');
|
||||||
|
});
|
||||||
|
|
||||||
|
redisClient.on('error', (err) => {
|
||||||
|
console.error('❌ Redis client error:', err);
|
||||||
|
});
|
||||||
|
|
||||||
|
redisClient.on('end', () => {
|
||||||
|
console.log('🔚 Redis client disconnected');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Connect to Redis
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
await redisClient.connect();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Failed to connect to Redis:', error);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
module.exports = redisClient;
|
||||||
279
src/controllers/adminController.js
Normal file
279
src/controllers/adminController.js
Normal file
@ -0,0 +1,279 @@
|
|||||||
|
const { Product, Reseller, User, Customer, Invoice, WalletTransaction, AuditLog } = require('../models');
|
||||||
|
const { Op } = require('sequelize');
|
||||||
|
|
||||||
|
// Create new product (admin only)
|
||||||
|
const createProduct = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
category,
|
||||||
|
subcategory,
|
||||||
|
sku,
|
||||||
|
basePrice,
|
||||||
|
currency = 'INR',
|
||||||
|
billingType,
|
||||||
|
billingCycle,
|
||||||
|
unit,
|
||||||
|
specifications = {},
|
||||||
|
features = [],
|
||||||
|
tierPricing = {
|
||||||
|
bronze: { margin: 20 },
|
||||||
|
silver: { margin: 25 },
|
||||||
|
gold: { margin: 30 },
|
||||||
|
platinum: { margin: 35 },
|
||||||
|
diamond: { margin: 40 }
|
||||||
|
},
|
||||||
|
availability = { regions: [], zones: [] },
|
||||||
|
tags = []
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
const product = await Product.create({
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
category,
|
||||||
|
subcategory,
|
||||||
|
sku,
|
||||||
|
basePrice,
|
||||||
|
currency,
|
||||||
|
billingType,
|
||||||
|
billingCycle,
|
||||||
|
unit,
|
||||||
|
specifications,
|
||||||
|
features,
|
||||||
|
tierPricing,
|
||||||
|
availability,
|
||||||
|
tags,
|
||||||
|
createdBy: req.user.id
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log audit
|
||||||
|
await AuditLog.create({
|
||||||
|
userId: req.user.id,
|
||||||
|
action: 'PRODUCT_CREATED',
|
||||||
|
resource: 'product',
|
||||||
|
resourceId: product.id,
|
||||||
|
newValues: product.toJSON(),
|
||||||
|
ipAddress: req.ip,
|
||||||
|
userAgent: req.get('User-Agent')
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Product created successfully',
|
||||||
|
data: { product }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Create product error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to create product'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update product (admin only)
|
||||||
|
const updateProduct = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { productId } = req.params;
|
||||||
|
const updates = req.body;
|
||||||
|
|
||||||
|
const product = await Product.findByPk(productId);
|
||||||
|
if (!product) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Product not found'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldValues = product.toJSON();
|
||||||
|
await product.update({
|
||||||
|
...updates,
|
||||||
|
updatedBy: req.user.id
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log audit
|
||||||
|
await AuditLog.create({
|
||||||
|
userId: req.user.id,
|
||||||
|
action: 'PRODUCT_UPDATED',
|
||||||
|
resource: 'product',
|
||||||
|
resourceId: product.id,
|
||||||
|
oldValues,
|
||||||
|
newValues: updates,
|
||||||
|
ipAddress: req.ip,
|
||||||
|
userAgent: req.get('User-Agent')
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Product updated successfully',
|
||||||
|
data: { product }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Update product error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to update product'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get all products (admin view)
|
||||||
|
const getAllProducts = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { page = 1, limit = 20, category, status, search } = req.query;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
const where = {};
|
||||||
|
if (category) where.category = category;
|
||||||
|
if (status) where.status = status;
|
||||||
|
if (search) {
|
||||||
|
where[Op.or] = [
|
||||||
|
{ name: { [Op.iLike]: `%${search}%` } },
|
||||||
|
{ description: { [Op.iLike]: `%${search}%` } },
|
||||||
|
{ sku: { [Op.iLike]: `%${search}%` } }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const { count, rows: products } = await Product.findAndCountAll({
|
||||||
|
where,
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: User,
|
||||||
|
as: 'creator',
|
||||||
|
attributes: ['id', 'firstName', 'lastName']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: User,
|
||||||
|
as: 'updater',
|
||||||
|
attributes: ['id', 'firstName', 'lastName']
|
||||||
|
}
|
||||||
|
],
|
||||||
|
limit: parseInt(limit),
|
||||||
|
offset: parseInt(offset),
|
||||||
|
order: [['createdAt', 'DESC']]
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
products,
|
||||||
|
pagination: {
|
||||||
|
total: count,
|
||||||
|
page: parseInt(page),
|
||||||
|
pages: Math.ceil(count / limit),
|
||||||
|
limit: parseInt(limit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get all products error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to fetch products'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get system statistics
|
||||||
|
const getSystemStats = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const [
|
||||||
|
totalResellers,
|
||||||
|
activeResellers,
|
||||||
|
pendingResellers,
|
||||||
|
totalCustomers,
|
||||||
|
activeCustomers,
|
||||||
|
totalProducts,
|
||||||
|
activeProducts,
|
||||||
|
totalRevenue,
|
||||||
|
monthlyRevenue
|
||||||
|
] = await Promise.all([
|
||||||
|
Reseller.count(),
|
||||||
|
Reseller.count({ where: { status: 'active' } }),
|
||||||
|
Reseller.count({ where: { status: 'pending_approval' } }),
|
||||||
|
Customer.count(),
|
||||||
|
Customer.count({ where: { status: 'active' } }),
|
||||||
|
Product.count(),
|
||||||
|
Product.count({ where: { status: 'active' } }),
|
||||||
|
Invoice.sum('totalAmount', { where: { status: 'paid' } }),
|
||||||
|
Invoice.sum('totalAmount', {
|
||||||
|
where: {
|
||||||
|
status: 'paid',
|
||||||
|
paidAt: {
|
||||||
|
[Op.gte]: new Date(new Date().getFullYear(), new Date().getMonth(), 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
const stats = {
|
||||||
|
resellers: {
|
||||||
|
total: totalResellers,
|
||||||
|
active: activeResellers,
|
||||||
|
pending: pendingResellers
|
||||||
|
},
|
||||||
|
customers: {
|
||||||
|
total: totalCustomers,
|
||||||
|
active: activeCustomers
|
||||||
|
},
|
||||||
|
products: {
|
||||||
|
total: totalProducts,
|
||||||
|
active: activeProducts
|
||||||
|
},
|
||||||
|
revenue: {
|
||||||
|
total: parseFloat(totalRevenue || 0),
|
||||||
|
monthly: parseFloat(monthlyRevenue || 0)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: { stats }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get system stats error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to fetch system statistics'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get recent activity
|
||||||
|
const getRecentActivity = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { limit = 20 } = req.query;
|
||||||
|
|
||||||
|
const recentActivity = await AuditLog.findAll({
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: User,
|
||||||
|
as: 'user',
|
||||||
|
attributes: ['id', 'firstName', 'lastName', 'email']
|
||||||
|
}
|
||||||
|
],
|
||||||
|
limit: parseInt(limit),
|
||||||
|
order: [['createdAt', 'DESC']]
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: { recentActivity }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get recent activity error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to fetch recent activity'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createProduct,
|
||||||
|
updateProduct,
|
||||||
|
getAllProducts,
|
||||||
|
getSystemStats,
|
||||||
|
getRecentActivity
|
||||||
|
};
|
||||||
350
src/controllers/authController.js
Normal file
350
src/controllers/authController.js
Normal file
@ -0,0 +1,350 @@
|
|||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const speakeasy = require('speakeasy');
|
||||||
|
const QRCode = require('qrcode');
|
||||||
|
const { User, UserSession, AuditLog } = require('../models');
|
||||||
|
const { sendEmail } = require('../config/email');
|
||||||
|
const redisClient = require('../config/redis');
|
||||||
|
|
||||||
|
// Generate JWT tokens
|
||||||
|
const generateTokens = (userId) => {
|
||||||
|
const accessToken = jwt.sign(
|
||||||
|
{ userId, type: 'access' },
|
||||||
|
process.env.JWT_SECRET,
|
||||||
|
{ expiresIn: process.env.JWT_EXPIRES_IN || '15m' }
|
||||||
|
);
|
||||||
|
|
||||||
|
const refreshToken = jwt.sign(
|
||||||
|
{ userId, type: 'refresh' },
|
||||||
|
process.env.JWT_REFRESH_SECRET,
|
||||||
|
{ expiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '7d' }
|
||||||
|
);
|
||||||
|
|
||||||
|
return { accessToken, refreshToken };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Register new user
|
||||||
|
const register = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { email, password, firstName, lastName, phone, role = 'read_only' } = req.body;
|
||||||
|
|
||||||
|
// Check if user already exists
|
||||||
|
const existingUser = await User.findOne({ where: { email } });
|
||||||
|
if (existingUser) {
|
||||||
|
return res.status(409).json({
|
||||||
|
success: false,
|
||||||
|
message: 'User already exists with this email'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate email verification token
|
||||||
|
const emailVerificationToken = crypto.randomBytes(32).toString('hex');
|
||||||
|
const emailVerificationExpires = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours
|
||||||
|
|
||||||
|
// Create user
|
||||||
|
const user = await User.create({
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
phone,
|
||||||
|
role,
|
||||||
|
emailVerificationToken,
|
||||||
|
emailVerificationExpires
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send verification email
|
||||||
|
const verificationUrl = `${process.env.FRONTEND_URL}/verify-email?token=${emailVerificationToken}`;
|
||||||
|
await sendEmail(email, 'emailVerification', {
|
||||||
|
name: `${firstName} ${lastName}`,
|
||||||
|
url: verificationUrl,
|
||||||
|
otp: emailVerificationToken.substring(0, 6).toUpperCase()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log audit
|
||||||
|
await AuditLog.create({
|
||||||
|
userId: user.id,
|
||||||
|
action: 'USER_REGISTERED',
|
||||||
|
resource: 'user',
|
||||||
|
resourceId: user.id,
|
||||||
|
ipAddress: req.ip,
|
||||||
|
userAgent: req.get('User-Agent')
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
message: 'User registered successfully. Please check your email for verification.',
|
||||||
|
data: {
|
||||||
|
user: user.toJSON()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Registration error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Registration failed',
|
||||||
|
error: process.env.NODE_ENV === 'development' ? error.message : undefined
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Login user
|
||||||
|
const login = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { email, password, mfaToken } = req.body;
|
||||||
|
|
||||||
|
// Find user
|
||||||
|
const user = await User.findOne({
|
||||||
|
where: { email },
|
||||||
|
include: ['reseller']
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Invalid credentials'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if account is locked
|
||||||
|
if (user.isLocked()) {
|
||||||
|
return res.status(423).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Account is temporarily locked due to multiple failed login attempts'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate password
|
||||||
|
const isValidPassword = await user.validatePassword(password);
|
||||||
|
if (!isValidPassword) {
|
||||||
|
await user.incrementLoginAttempts();
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Invalid credentials'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if email is verified
|
||||||
|
if (!user.emailVerified) {
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Please verify your email before logging in'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check account status
|
||||||
|
if (user.status !== 'active') {
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Account is not active'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check MFA if enabled
|
||||||
|
if (user.mfaEnabled) {
|
||||||
|
if (!mfaToken) {
|
||||||
|
return res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
requiresMFA: true,
|
||||||
|
message: 'MFA token required'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMfaValid = user.verifyMfaToken(mfaToken);
|
||||||
|
if (!isMfaValid) {
|
||||||
|
await user.incrementLoginAttempts();
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Invalid MFA token'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate tokens
|
||||||
|
const { accessToken, refreshToken } = generateTokens(user.id);
|
||||||
|
|
||||||
|
// Create session
|
||||||
|
const session = await UserSession.create({
|
||||||
|
userId: user.id,
|
||||||
|
refreshToken,
|
||||||
|
deviceInfo: {
|
||||||
|
userAgent: req.get('User-Agent'),
|
||||||
|
platform: req.get('sec-ch-ua-platform')
|
||||||
|
},
|
||||||
|
ipAddress: req.ip,
|
||||||
|
userAgent: req.get('User-Agent'),
|
||||||
|
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) // 7 days
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset login attempts and update last login
|
||||||
|
await user.resetLoginAttempts();
|
||||||
|
|
||||||
|
// Log audit
|
||||||
|
await AuditLog.create({
|
||||||
|
userId: user.id,
|
||||||
|
action: 'USER_LOGIN',
|
||||||
|
resource: 'user',
|
||||||
|
resourceId: user.id,
|
||||||
|
ipAddress: req.ip,
|
||||||
|
userAgent: req.get('User-Agent')
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Login successful',
|
||||||
|
data: {
|
||||||
|
user: user.toJSON(),
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
expiresIn: process.env.JWT_EXPIRES_IN || '15m'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Login error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Login failed',
|
||||||
|
error: process.env.NODE_ENV === 'development' ? error.message : undefined
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Refresh token
|
||||||
|
const refreshToken = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { refreshToken } = req.body;
|
||||||
|
|
||||||
|
const session = await UserSession.findOne({
|
||||||
|
where: {
|
||||||
|
refreshToken,
|
||||||
|
isActive: true
|
||||||
|
},
|
||||||
|
include: ['user']
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session || session.isExpired()) {
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Invalid or expired refresh token'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new tokens
|
||||||
|
const { accessToken, refreshToken: newRefreshToken } = generateTokens(session.userId);
|
||||||
|
|
||||||
|
// Update session
|
||||||
|
await session.update({
|
||||||
|
refreshToken: newRefreshToken,
|
||||||
|
lastUsedAt: new Date()
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
accessToken,
|
||||||
|
refreshToken: newRefreshToken,
|
||||||
|
expiresIn: process.env.JWT_EXPIRES_IN || '15m'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Token refresh error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Token refresh failed'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Logout
|
||||||
|
const logout = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const authHeader = req.headers['authorization'];
|
||||||
|
const token = authHeader && authHeader.split(' ')[1];
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
// Blacklist the access token
|
||||||
|
const decoded = jwt.decode(token);
|
||||||
|
if (decoded && decoded.exp) {
|
||||||
|
const ttl = decoded.exp - Math.floor(Date.now() / 1000);
|
||||||
|
if (ttl > 0) {
|
||||||
|
await redisClient.setEx(`blacklist:${token}`, ttl, 'true');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deactivate refresh token session
|
||||||
|
const { refreshToken } = req.body;
|
||||||
|
if (refreshToken) {
|
||||||
|
await UserSession.update(
|
||||||
|
{ isActive: false },
|
||||||
|
{ where: { refreshToken } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log audit
|
||||||
|
await AuditLog.create({
|
||||||
|
userId: req.user?.id,
|
||||||
|
action: 'USER_LOGOUT',
|
||||||
|
resource: 'user',
|
||||||
|
resourceId: req.user?.id,
|
||||||
|
ipAddress: req.ip,
|
||||||
|
userAgent: req.get('User-Agent')
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Logout successful'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Logout error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Logout failed'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Setup MFA
|
||||||
|
const setupMFA = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const user = req.user;
|
||||||
|
|
||||||
|
if (user.mfaEnabled) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'MFA is already enabled'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const secret = user.generateMfaSecret();
|
||||||
|
const backupCodes = user.generateMfaBackupCodes();
|
||||||
|
|
||||||
|
await user.save();
|
||||||
|
|
||||||
|
// Generate QR code
|
||||||
|
const qrCodeUrl = await QRCode.toDataURL(secret.otpauth_url);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
secret: secret.base32,
|
||||||
|
qrCode: qrCodeUrl,
|
||||||
|
backupCodes
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('MFA setup error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'MFA setup failed'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
register,
|
||||||
|
login,
|
||||||
|
refreshToken,
|
||||||
|
logout,
|
||||||
|
setupMFA
|
||||||
|
};
|
||||||
489
src/controllers/billingController.js
Normal file
489
src/controllers/billingController.js
Normal file
@ -0,0 +1,489 @@
|
|||||||
|
const { Wallet, WalletTransaction, Invoice, InvoiceItem, Customer, AuditLog } = require('../models');
|
||||||
|
const { Op } = require('sequelize');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
// Get wallet details
|
||||||
|
const getWallet = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const wallet = await Wallet.findOne({
|
||||||
|
where: { resellerId: req.user.resellerId },
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: WalletTransaction,
|
||||||
|
as: 'transactions',
|
||||||
|
limit: 10,
|
||||||
|
order: [['createdAt', 'DESC']]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!wallet) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Wallet not found'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const walletData = wallet.toJSON();
|
||||||
|
walletData.availableBalance = wallet.getAvailableBalance();
|
||||||
|
walletData.isLowBalance = wallet.isLowBalance();
|
||||||
|
walletData.shouldAutoRecharge = wallet.shouldAutoRecharge();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: { wallet: walletData }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get wallet error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to fetch wallet details'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add funds to wallet
|
||||||
|
const addFunds = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { amount, paymentMethod = 'manual', paymentReference, description } = req.body;
|
||||||
|
|
||||||
|
const wallet = await Wallet.findOne({
|
||||||
|
where: { resellerId: req.user.resellerId }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!wallet) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Wallet not found'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const transactionId = `ADD_${Date.now()}_${crypto.randomBytes(4).toString('hex').toUpperCase()}`;
|
||||||
|
const balanceBefore = parseFloat(wallet.balance);
|
||||||
|
const addAmount = parseFloat(amount);
|
||||||
|
const balanceAfter = balanceBefore + addAmount;
|
||||||
|
|
||||||
|
// Create transaction record
|
||||||
|
const transaction = await WalletTransaction.create({
|
||||||
|
walletId: wallet.id,
|
||||||
|
transactionId,
|
||||||
|
type: 'credit',
|
||||||
|
category: 'payment',
|
||||||
|
amount: addAmount,
|
||||||
|
balanceBefore,
|
||||||
|
balanceAfter,
|
||||||
|
status: 'completed',
|
||||||
|
description: description || `Funds added via ${paymentMethod}`,
|
||||||
|
paymentGateway: paymentMethod,
|
||||||
|
gatewayTransactionId: paymentReference,
|
||||||
|
processedAt: new Date(),
|
||||||
|
processedBy: req.user.id
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update wallet balance
|
||||||
|
await wallet.addFunds(addAmount, transaction.id);
|
||||||
|
|
||||||
|
// Log audit
|
||||||
|
await AuditLog.create({
|
||||||
|
userId: req.user.id,
|
||||||
|
action: 'FUNDS_ADDED',
|
||||||
|
resource: 'wallet',
|
||||||
|
resourceId: wallet.id,
|
||||||
|
newValues: { amount: addAmount, transactionId },
|
||||||
|
ipAddress: req.ip,
|
||||||
|
userAgent: req.get('User-Agent')
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Funds added successfully',
|
||||||
|
data: {
|
||||||
|
transaction,
|
||||||
|
newBalance: balanceAfter
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Add funds error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to add funds'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get wallet transactions
|
||||||
|
const getTransactions = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { page = 1, limit = 20, type, category, status, startDate, endDate } = req.query;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
const wallet = await Wallet.findOne({
|
||||||
|
where: { resellerId: req.user.resellerId }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!wallet) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Wallet not found'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const where = { walletId: wallet.id };
|
||||||
|
if (type) where.type = type;
|
||||||
|
if (category) where.category = category;
|
||||||
|
if (status) where.status = status;
|
||||||
|
if (startDate && endDate) {
|
||||||
|
where.createdAt = {
|
||||||
|
[Op.between]: [new Date(startDate), new Date(endDate)]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const { count, rows: transactions } = await WalletTransaction.findAndCountAll({
|
||||||
|
where,
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: require('../models').User,
|
||||||
|
as: 'processor',
|
||||||
|
attributes: ['id', 'firstName', 'lastName', 'email']
|
||||||
|
}
|
||||||
|
],
|
||||||
|
limit: parseInt(limit),
|
||||||
|
offset: parseInt(offset),
|
||||||
|
order: [['createdAt', 'DESC']]
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
transactions,
|
||||||
|
pagination: {
|
||||||
|
total: count,
|
||||||
|
page: parseInt(page),
|
||||||
|
pages: Math.ceil(count / limit),
|
||||||
|
limit: parseInt(limit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get transactions error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to fetch transactions'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get invoices
|
||||||
|
const getInvoices = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { page = 1, limit = 20, status, customerId, startDate, endDate } = req.query;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
const where = { resellerId: req.user.resellerId };
|
||||||
|
if (status) where.status = status;
|
||||||
|
if (customerId) where.customerId = customerId;
|
||||||
|
if (startDate && endDate) {
|
||||||
|
where.createdAt = {
|
||||||
|
[Op.between]: [new Date(startDate), new Date(endDate)]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const { count, rows: invoices } = await Invoice.findAndCountAll({
|
||||||
|
where,
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: Customer,
|
||||||
|
as: 'customer',
|
||||||
|
attributes: ['id', 'companyName', 'contactPerson', 'email']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: require('../models').User,
|
||||||
|
as: 'creator',
|
||||||
|
attributes: ['id', 'firstName', 'lastName']
|
||||||
|
}
|
||||||
|
],
|
||||||
|
limit: parseInt(limit),
|
||||||
|
offset: parseInt(offset),
|
||||||
|
order: [['createdAt', 'DESC']]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add calculated fields
|
||||||
|
const invoicesWithCalc = invoices.map(invoice => {
|
||||||
|
const invoiceData = invoice.toJSON();
|
||||||
|
invoiceData.isOverdue = invoice.isOverdue();
|
||||||
|
invoiceData.daysOverdue = invoice.getDaysOverdue();
|
||||||
|
invoiceData.outstandingAmount = invoice.getOutstandingAmount();
|
||||||
|
return invoiceData;
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
invoices: invoicesWithCalc,
|
||||||
|
pagination: {
|
||||||
|
total: count,
|
||||||
|
page: parseInt(page),
|
||||||
|
pages: Math.ceil(count / limit),
|
||||||
|
limit: parseInt(limit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get invoices error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to fetch invoices'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get invoice details
|
||||||
|
const getInvoice = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { invoiceId } = req.params;
|
||||||
|
|
||||||
|
const invoice = await Invoice.findOne({
|
||||||
|
where: {
|
||||||
|
id: invoiceId,
|
||||||
|
resellerId: req.user.resellerId
|
||||||
|
},
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: Customer,
|
||||||
|
as: 'customer'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: require('../models').InvoiceItem,
|
||||||
|
as: 'items',
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: require('../models').Product,
|
||||||
|
as: 'product',
|
||||||
|
attributes: ['id', 'name', 'sku']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!invoice) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Invoice not found'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const invoiceData = invoice.toJSON();
|
||||||
|
invoiceData.isOverdue = invoice.isOverdue();
|
||||||
|
invoiceData.daysOverdue = invoice.getDaysOverdue();
|
||||||
|
invoiceData.outstandingAmount = invoice.getOutstandingAmount();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: { invoice: invoiceData }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get invoice error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to fetch invoice'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mark invoice as paid
|
||||||
|
const markInvoicePaid = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { invoiceId } = req.params;
|
||||||
|
const { amount, paymentMethod, paymentReference, notes } = req.body;
|
||||||
|
|
||||||
|
const invoice = await Invoice.findOne({
|
||||||
|
where: {
|
||||||
|
id: invoiceId,
|
||||||
|
resellerId: req.user.resellerId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!invoice) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Invoice not found'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await invoice.markAsPaid(amount, paymentMethod, paymentReference);
|
||||||
|
|
||||||
|
// Log audit
|
||||||
|
await AuditLog.create({
|
||||||
|
userId: req.user.id,
|
||||||
|
action: 'INVOICE_PAID',
|
||||||
|
resource: 'invoice',
|
||||||
|
resourceId: invoice.id,
|
||||||
|
newValues: { amount, paymentMethod, paymentReference, notes },
|
||||||
|
ipAddress: req.ip,
|
||||||
|
userAgent: req.get('User-Agent')
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Invoice marked as paid',
|
||||||
|
data: { invoice }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Mark invoice paid error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to mark invoice as paid'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Download invoice
|
||||||
|
const downloadInvoice = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { invoiceId } = req.params;
|
||||||
|
|
||||||
|
const invoice = await Invoice.findOne({
|
||||||
|
where: {
|
||||||
|
id: invoiceId,
|
||||||
|
resellerId: req.user.resellerId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!invoice) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Invoice not found'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increment download count
|
||||||
|
await invoice.incrementDownload();
|
||||||
|
|
||||||
|
// For now, return the invoice data (PDF generation would be implemented separately)
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Invoice download initiated',
|
||||||
|
data: {
|
||||||
|
downloadUrl: `/api/billing/invoices/${invoiceId}/pdf`,
|
||||||
|
invoice
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Download invoice error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to download invoice'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get billing summary
|
||||||
|
const getBillingSummary = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { period = 'month' } = req.query;
|
||||||
|
|
||||||
|
const wallet = await Wallet.findOne({
|
||||||
|
where: { resellerId: req.user.resellerId }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!wallet) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Wallet not found'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate date range based on period
|
||||||
|
let startDate;
|
||||||
|
const endDate = new Date();
|
||||||
|
|
||||||
|
switch (period) {
|
||||||
|
case 'week':
|
||||||
|
startDate = new Date();
|
||||||
|
startDate.setDate(startDate.getDate() - 7);
|
||||||
|
break;
|
||||||
|
case 'month':
|
||||||
|
startDate = new Date();
|
||||||
|
startDate.setMonth(startDate.getMonth() - 1);
|
||||||
|
break;
|
||||||
|
case 'quarter':
|
||||||
|
startDate = new Date();
|
||||||
|
startDate.setMonth(startDate.getMonth() - 3);
|
||||||
|
break;
|
||||||
|
case 'year':
|
||||||
|
startDate = new Date();
|
||||||
|
startDate.setFullYear(startDate.getFullYear() - 1);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
startDate = new Date();
|
||||||
|
startDate.setMonth(startDate.getMonth() - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get transaction summary
|
||||||
|
const transactionSummary = await WalletTransaction.findAll({
|
||||||
|
where: {
|
||||||
|
walletId: wallet.id,
|
||||||
|
createdAt: { [Op.between]: [startDate, endDate] }
|
||||||
|
},
|
||||||
|
attributes: [
|
||||||
|
'type',
|
||||||
|
'category',
|
||||||
|
[require('sequelize').fn('SUM', require('sequelize').col('amount')), 'totalAmount'],
|
||||||
|
[require('sequelize').fn('COUNT', require('sequelize').col('id')), 'count']
|
||||||
|
],
|
||||||
|
group: ['type', 'category']
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get invoice summary
|
||||||
|
const invoiceSummary = await Invoice.findAll({
|
||||||
|
where: {
|
||||||
|
resellerId: req.user.resellerId,
|
||||||
|
createdAt: { [Op.between]: [startDate, endDate] }
|
||||||
|
},
|
||||||
|
attributes: [
|
||||||
|
'status',
|
||||||
|
[require('sequelize').fn('SUM', require('sequelize').col('total_amount')), 'totalAmount'],
|
||||||
|
[require('sequelize').fn('COUNT', require('sequelize').col('id')), 'count']
|
||||||
|
],
|
||||||
|
group: ['status']
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
wallet: {
|
||||||
|
balance: wallet.balance,
|
||||||
|
availableBalance: wallet.getAvailableBalance(),
|
||||||
|
reservedAmount: wallet.reservedAmount,
|
||||||
|
totalSpent: wallet.totalSpent,
|
||||||
|
totalAdded: wallet.totalAdded
|
||||||
|
},
|
||||||
|
transactionSummary,
|
||||||
|
invoiceSummary,
|
||||||
|
period: {
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
period
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get billing summary error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to fetch billing summary'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getWallet,
|
||||||
|
addFunds,
|
||||||
|
getTransactions,
|
||||||
|
getInvoices,
|
||||||
|
getInvoice,
|
||||||
|
markInvoicePaid,
|
||||||
|
downloadInvoice,
|
||||||
|
getBillingSummary
|
||||||
|
};
|
||||||
443
src/controllers/customerController.js
Normal file
443
src/controllers/customerController.js
Normal file
@ -0,0 +1,443 @@
|
|||||||
|
const { Customer, CustomerService, UsageRecord, Invoice, AuditLog, Product } = require('../models');
|
||||||
|
const { Op } = require('sequelize');
|
||||||
|
|
||||||
|
// Get all customers for the reseller
|
||||||
|
const getCustomers = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { page = 1, limit = 20, status, search, sortBy = 'createdAt', sortOrder = 'DESC' } = req.query;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
const where = { resellerId: req.user.resellerId };
|
||||||
|
if (status) where.status = status;
|
||||||
|
if (search) {
|
||||||
|
where[Op.or] = [
|
||||||
|
{ companyName: { [Op.iLike]: `%${search}%` } },
|
||||||
|
{ contactPerson: { [Op.iLike]: `%${search}%` } },
|
||||||
|
{ email: { [Op.iLike]: `%${search}%` } }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const { count, rows: customers } = await Customer.findAndCountAll({
|
||||||
|
where,
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: CustomerService,
|
||||||
|
as: 'services',
|
||||||
|
attributes: ['id', 'status', 'serviceName'],
|
||||||
|
required: false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
limit: parseInt(limit),
|
||||||
|
offset: parseInt(offset),
|
||||||
|
order: [[sortBy, sortOrder.toUpperCase()]]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add service counts and usage summary
|
||||||
|
const customersWithStats = await Promise.all(customers.map(async (customer) => {
|
||||||
|
const customerData = customer.toJSON();
|
||||||
|
|
||||||
|
// Count active services
|
||||||
|
customerData.activeServicesCount = customer.services?.filter(s => s.status === 'active').length || 0;
|
||||||
|
customerData.totalServicesCount = customer.services?.length || 0;
|
||||||
|
|
||||||
|
// Get current month usage
|
||||||
|
const currentMonth = new Date();
|
||||||
|
currentMonth.setDate(1);
|
||||||
|
currentMonth.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const monthlyUsage = await UsageRecord.sum('totalCost', {
|
||||||
|
where: {
|
||||||
|
customerId: customer.id,
|
||||||
|
usageDate: { [Op.gte]: currentMonth },
|
||||||
|
billingStatus: { [Op.in]: ['pending', 'billed'] }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
customerData.monthlyUsage = parseFloat(monthlyUsage || 0);
|
||||||
|
|
||||||
|
return customerData;
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
customers: customersWithStats,
|
||||||
|
pagination: {
|
||||||
|
total: count,
|
||||||
|
page: parseInt(page),
|
||||||
|
pages: Math.ceil(count / limit),
|
||||||
|
limit: parseInt(limit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get customers error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to fetch customers'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get customer details
|
||||||
|
const getCustomer = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { customerId } = req.params;
|
||||||
|
|
||||||
|
const customer = await Customer.findOne({
|
||||||
|
where: {
|
||||||
|
id: customerId,
|
||||||
|
resellerId: req.user.resellerId
|
||||||
|
},
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: CustomerService,
|
||||||
|
as: 'services',
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: Product,
|
||||||
|
as: 'product',
|
||||||
|
attributes: ['id', 'name', 'category', 'sku']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: Invoice,
|
||||||
|
as: 'invoices',
|
||||||
|
limit: 10,
|
||||||
|
order: [['createdAt', 'DESC']]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!customer) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Customer not found'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get usage summary for last 6 months
|
||||||
|
const sixMonthsAgo = new Date();
|
||||||
|
sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 6);
|
||||||
|
|
||||||
|
const usageSummary = await UsageRecord.findAll({
|
||||||
|
where: {
|
||||||
|
customerId: customer.id,
|
||||||
|
usageDate: { [Op.gte]: sixMonthsAgo }
|
||||||
|
},
|
||||||
|
attributes: [
|
||||||
|
[require('sequelize').fn('DATE_TRUNC', 'month', require('sequelize').col('usage_date')), 'month'],
|
||||||
|
[require('sequelize').fn('SUM', require('sequelize').col('total_cost')), 'totalCost'],
|
||||||
|
[require('sequelize').fn('SUM', require('sequelize').col('quantity')), 'totalQuantity']
|
||||||
|
],
|
||||||
|
group: [require('sequelize').fn('DATE_TRUNC', 'month', require('sequelize').col('usage_date'))],
|
||||||
|
order: [[require('sequelize').fn('DATE_TRUNC', 'month', require('sequelize').col('usage_date')), 'ASC']]
|
||||||
|
});
|
||||||
|
|
||||||
|
const customerData = customer.toJSON();
|
||||||
|
customerData.usageSummary = usageSummary;
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: { customer: customerData }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get customer error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to fetch customer'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create new customer
|
||||||
|
const createCustomer = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
companyName,
|
||||||
|
contactPerson,
|
||||||
|
email,
|
||||||
|
phone,
|
||||||
|
address,
|
||||||
|
industry,
|
||||||
|
companySize,
|
||||||
|
billingAddress,
|
||||||
|
paymentMethod = 'invoice',
|
||||||
|
creditLimit = 0,
|
||||||
|
taxId,
|
||||||
|
gstNumber,
|
||||||
|
notes
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
const customer = await Customer.create({
|
||||||
|
resellerId: req.user.resellerId,
|
||||||
|
companyName,
|
||||||
|
contactPerson,
|
||||||
|
email,
|
||||||
|
phone,
|
||||||
|
address,
|
||||||
|
industry,
|
||||||
|
companySize,
|
||||||
|
billingAddress: billingAddress || address,
|
||||||
|
paymentMethod,
|
||||||
|
creditLimit,
|
||||||
|
taxId,
|
||||||
|
gstNumber,
|
||||||
|
notes,
|
||||||
|
createdBy: req.user.id
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log audit
|
||||||
|
await AuditLog.create({
|
||||||
|
userId: req.user.id,
|
||||||
|
action: 'CUSTOMER_CREATED',
|
||||||
|
resource: 'customer',
|
||||||
|
resourceId: customer.id,
|
||||||
|
newValues: customer.toJSON(),
|
||||||
|
ipAddress: req.ip,
|
||||||
|
userAgent: req.get('User-Agent')
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Customer created successfully',
|
||||||
|
data: { customer }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Create customer error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to create customer'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update customer
|
||||||
|
const updateCustomer = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { customerId } = req.params;
|
||||||
|
const updates = req.body;
|
||||||
|
|
||||||
|
const customer = await Customer.findOne({
|
||||||
|
where: {
|
||||||
|
id: customerId,
|
||||||
|
resellerId: req.user.resellerId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!customer) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Customer not found'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldValues = customer.toJSON();
|
||||||
|
await customer.update(updates);
|
||||||
|
|
||||||
|
// Log audit
|
||||||
|
await AuditLog.create({
|
||||||
|
userId: req.user.id,
|
||||||
|
action: 'CUSTOMER_UPDATED',
|
||||||
|
resource: 'customer',
|
||||||
|
resourceId: customer.id,
|
||||||
|
oldValues,
|
||||||
|
newValues: updates,
|
||||||
|
ipAddress: req.ip,
|
||||||
|
userAgent: req.get('User-Agent')
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Customer updated successfully',
|
||||||
|
data: { customer }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Update customer error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to update customer'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get customer usage details
|
||||||
|
const getCustomerUsage = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { customerId } = req.params;
|
||||||
|
const { startDate, endDate, serviceId, metricType } = req.query;
|
||||||
|
|
||||||
|
// Verify customer belongs to reseller
|
||||||
|
const customer = await Customer.findOne({
|
||||||
|
where: {
|
||||||
|
id: customerId,
|
||||||
|
resellerId: req.user.resellerId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!customer) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Customer not found'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const where = { customerId };
|
||||||
|
|
||||||
|
if (startDate && endDate) {
|
||||||
|
where.usageDate = {
|
||||||
|
[Op.between]: [new Date(startDate), new Date(endDate)]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (serviceId) where.serviceId = serviceId;
|
||||||
|
if (metricType) where.metricType = metricType;
|
||||||
|
|
||||||
|
const usageRecords = await UsageRecord.findAll({
|
||||||
|
where,
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: CustomerService,
|
||||||
|
as: 'service',
|
||||||
|
attributes: ['id', 'serviceName', 'status']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: Product,
|
||||||
|
as: 'product',
|
||||||
|
attributes: ['id', 'name', 'category']
|
||||||
|
}
|
||||||
|
],
|
||||||
|
order: [['usageDate', 'DESC'], ['usageHour', 'DESC']]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Group usage by service and metric
|
||||||
|
const usageSummary = usageRecords.reduce((acc, record) => {
|
||||||
|
const key = `${record.serviceId}_${record.metricType}`;
|
||||||
|
if (!acc[key]) {
|
||||||
|
acc[key] = {
|
||||||
|
serviceId: record.serviceId,
|
||||||
|
serviceName: record.service.serviceName,
|
||||||
|
metricType: record.metricType,
|
||||||
|
unit: record.unit,
|
||||||
|
totalQuantity: 0,
|
||||||
|
totalCost: 0,
|
||||||
|
records: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
acc[key].totalQuantity += parseFloat(record.quantity);
|
||||||
|
acc[key].totalCost += parseFloat(record.totalCost);
|
||||||
|
acc[key].records.push(record);
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
usageRecords,
|
||||||
|
usageSummary: Object.values(usageSummary)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get customer usage error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to fetch customer usage'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Assign service to customer
|
||||||
|
const assignService = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { customerId } = req.params;
|
||||||
|
const {
|
||||||
|
productId,
|
||||||
|
serviceName,
|
||||||
|
configuration = {},
|
||||||
|
specifications = {},
|
||||||
|
region,
|
||||||
|
zone,
|
||||||
|
pricing,
|
||||||
|
billingCycle = 'monthly',
|
||||||
|
contractPeriod
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// Verify customer belongs to reseller
|
||||||
|
const customer = await Customer.findOne({
|
||||||
|
where: {
|
||||||
|
id: customerId,
|
||||||
|
resellerId: req.user.resellerId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!customer) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Customer not found'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify product exists
|
||||||
|
const product = await Product.findByPk(productId);
|
||||||
|
if (!product) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Product not found'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate unique service instance ID
|
||||||
|
const serviceInstanceId = `${product.sku}_${customerId.slice(-8)}_${Date.now()}`;
|
||||||
|
|
||||||
|
const customerService = await CustomerService.create({
|
||||||
|
customerId,
|
||||||
|
productId,
|
||||||
|
serviceInstanceId,
|
||||||
|
serviceName,
|
||||||
|
configuration,
|
||||||
|
specifications,
|
||||||
|
region,
|
||||||
|
zone,
|
||||||
|
pricing,
|
||||||
|
billingCycle,
|
||||||
|
contractPeriod,
|
||||||
|
contractEndDate: contractPeriod ?
|
||||||
|
new Date(Date.now() + contractPeriod * 30 * 24 * 60 * 60 * 1000) : null
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log audit
|
||||||
|
await AuditLog.create({
|
||||||
|
userId: req.user.id,
|
||||||
|
action: 'SERVICE_ASSIGNED',
|
||||||
|
resource: 'customer_service',
|
||||||
|
resourceId: customerService.id,
|
||||||
|
newValues: customerService.toJSON(),
|
||||||
|
ipAddress: req.ip,
|
||||||
|
userAgent: req.get('User-Agent')
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Service assigned successfully',
|
||||||
|
data: { customerService }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Assign service error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to assign service'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getCustomers,
|
||||||
|
getCustomer,
|
||||||
|
createCustomer,
|
||||||
|
updateCustomer,
|
||||||
|
getCustomerUsage,
|
||||||
|
assignService
|
||||||
|
};
|
||||||
408
src/controllers/dashboardController.js
Normal file
408
src/controllers/dashboardController.js
Normal file
@ -0,0 +1,408 @@
|
|||||||
|
const { Customer, CustomerService, UsageRecord, Invoice, WalletTransaction, Wallet, Product } = require('../models');
|
||||||
|
const { Op } = require('sequelize');
|
||||||
|
|
||||||
|
// Get dashboard overview
|
||||||
|
const getDashboardOverview = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const resellerId = req.user.resellerId;
|
||||||
|
|
||||||
|
// Get date ranges
|
||||||
|
const now = new Date();
|
||||||
|
const currentMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
|
const lastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
||||||
|
const currentYear = new Date(now.getFullYear(), 0, 1);
|
||||||
|
|
||||||
|
// Get wallet info
|
||||||
|
const wallet = await Wallet.findOne({
|
||||||
|
where: { resellerId }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get KPI data
|
||||||
|
const [
|
||||||
|
totalCustomers,
|
||||||
|
activeCustomers,
|
||||||
|
totalServices,
|
||||||
|
activeServices,
|
||||||
|
monthlyRevenue,
|
||||||
|
lastMonthRevenue,
|
||||||
|
yearlyRevenue,
|
||||||
|
pendingInvoices,
|
||||||
|
overdueInvoices
|
||||||
|
] = await Promise.all([
|
||||||
|
// Total customers
|
||||||
|
Customer.count({ where: { resellerId } }),
|
||||||
|
|
||||||
|
// Active customers
|
||||||
|
Customer.count({ where: { resellerId, status: 'active' } }),
|
||||||
|
|
||||||
|
// Total services
|
||||||
|
CustomerService.count({
|
||||||
|
include: [{ model: Customer, as: 'customer', where: { resellerId } }]
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Active services
|
||||||
|
CustomerService.count({
|
||||||
|
where: { status: 'active' },
|
||||||
|
include: [{ model: Customer, as: 'customer', where: { resellerId } }]
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Current month revenue
|
||||||
|
Invoice.sum('totalAmount', {
|
||||||
|
where: {
|
||||||
|
resellerId,
|
||||||
|
status: 'paid',
|
||||||
|
paidAt: { [Op.gte]: currentMonth }
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Last month revenue
|
||||||
|
Invoice.sum('totalAmount', {
|
||||||
|
where: {
|
||||||
|
resellerId,
|
||||||
|
status: 'paid',
|
||||||
|
paidAt: { [Op.between]: [lastMonth, currentMonth] }
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Yearly revenue
|
||||||
|
Invoice.sum('totalAmount', {
|
||||||
|
where: {
|
||||||
|
resellerId,
|
||||||
|
status: 'paid',
|
||||||
|
paidAt: { [Op.gte]: currentYear }
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Pending invoices count
|
||||||
|
Invoice.count({
|
||||||
|
where: {
|
||||||
|
resellerId,
|
||||||
|
status: { [Op.in]: ['sent', 'overdue'] }
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Overdue invoices count
|
||||||
|
Invoice.count({
|
||||||
|
where: {
|
||||||
|
resellerId,
|
||||||
|
status: 'overdue'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Calculate growth percentages
|
||||||
|
const revenueGrowth = lastMonthRevenue > 0
|
||||||
|
? ((monthlyRevenue - lastMonthRevenue) / lastMonthRevenue * 100).toFixed(1)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const kpis = {
|
||||||
|
totalRevenue: parseFloat(yearlyRevenue || 0),
|
||||||
|
monthlyRevenue: parseFloat(monthlyRevenue || 0),
|
||||||
|
revenueGrowth: parseFloat(revenueGrowth),
|
||||||
|
totalCustomers,
|
||||||
|
activeCustomers,
|
||||||
|
customerGrowth: 0, // Would need historical data
|
||||||
|
totalServices,
|
||||||
|
activeServices,
|
||||||
|
walletBalance: wallet ? parseFloat(wallet.balance) : 0,
|
||||||
|
pendingInvoices,
|
||||||
|
overdueInvoices
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: { kpis }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get dashboard overview error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to fetch dashboard overview'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get revenue chart data
|
||||||
|
const getRevenueChart = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { period = 'month', range = 12 } = req.query;
|
||||||
|
const resellerId = req.user.resellerId;
|
||||||
|
|
||||||
|
let dateFormat, dateInterval;
|
||||||
|
switch (period) {
|
||||||
|
case 'day':
|
||||||
|
dateFormat = 'YYYY-MM-DD';
|
||||||
|
dateInterval = 'day';
|
||||||
|
break;
|
||||||
|
case 'week':
|
||||||
|
dateFormat = 'YYYY-"W"WW';
|
||||||
|
dateInterval = 'week';
|
||||||
|
break;
|
||||||
|
case 'month':
|
||||||
|
dateFormat = 'YYYY-MM';
|
||||||
|
dateInterval = 'month';
|
||||||
|
break;
|
||||||
|
case 'quarter':
|
||||||
|
dateFormat = 'YYYY-"Q"Q';
|
||||||
|
dateInterval = 'quarter';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
dateFormat = 'YYYY-MM';
|
||||||
|
dateInterval = 'month';
|
||||||
|
}
|
||||||
|
|
||||||
|
const startDate = new Date();
|
||||||
|
startDate.setMonth(startDate.getMonth() - parseInt(range));
|
||||||
|
|
||||||
|
const revenueData = await Invoice.findAll({
|
||||||
|
where: {
|
||||||
|
resellerId,
|
||||||
|
status: 'paid',
|
||||||
|
paidAt: { [Op.gte]: startDate }
|
||||||
|
},
|
||||||
|
attributes: [
|
||||||
|
[require('sequelize').fn('DATE_TRUNC', dateInterval, require('sequelize').col('paid_at')), 'period'],
|
||||||
|
[require('sequelize').fn('SUM', require('sequelize').col('total_amount')), 'revenue'],
|
||||||
|
[require('sequelize').fn('COUNT', require('sequelize').col('id')), 'invoiceCount']
|
||||||
|
],
|
||||||
|
group: [require('sequelize').fn('DATE_TRUNC', dateInterval, require('sequelize').col('paid_at'))],
|
||||||
|
order: [[require('sequelize').fn('DATE_TRUNC', dateInterval, require('sequelize').col('paid_at')), 'ASC']]
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: { revenueData }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get revenue chart error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to fetch revenue chart data'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get service distribution chart data
|
||||||
|
const getServiceDistribution = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const resellerId = req.user.resellerId;
|
||||||
|
|
||||||
|
const serviceDistribution = await CustomerService.findAll({
|
||||||
|
where: { status: 'active' },
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: Customer,
|
||||||
|
as: 'customer',
|
||||||
|
where: { resellerId },
|
||||||
|
attributes: []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: Product,
|
||||||
|
as: 'product',
|
||||||
|
attributes: ['category', 'name']
|
||||||
|
}
|
||||||
|
],
|
||||||
|
attributes: [
|
||||||
|
[require('sequelize').col('product.category'), 'category'],
|
||||||
|
[require('sequelize').fn('COUNT', require('sequelize').col('CustomerService.id')), 'count']
|
||||||
|
],
|
||||||
|
group: [require('sequelize').col('product.category')],
|
||||||
|
order: [[require('sequelize').fn('COUNT', require('sequelize').col('CustomerService.id')), 'DESC']]
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: { serviceDistribution }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get service distribution error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to fetch service distribution data'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get recent customers
|
||||||
|
const getRecentCustomers = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { limit = 5 } = req.query;
|
||||||
|
const resellerId = req.user.resellerId;
|
||||||
|
|
||||||
|
const recentCustomers = await Customer.findAll({
|
||||||
|
where: { resellerId },
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: CustomerService,
|
||||||
|
as: 'services',
|
||||||
|
attributes: ['id', 'status'],
|
||||||
|
required: false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
limit: parseInt(limit),
|
||||||
|
order: [['createdAt', 'DESC']]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add service counts
|
||||||
|
const customersWithStats = recentCustomers.map(customer => {
|
||||||
|
const customerData = customer.toJSON();
|
||||||
|
customerData.activeServicesCount = customer.services?.filter(s => s.status === 'active').length || 0;
|
||||||
|
customerData.totalServicesCount = customer.services?.length || 0;
|
||||||
|
return customerData;
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: { customers: customersWithStats }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get recent customers error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to fetch recent customers'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get recent transactions
|
||||||
|
const getRecentTransactions = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { limit = 5 } = req.query;
|
||||||
|
const resellerId = req.user.resellerId;
|
||||||
|
|
||||||
|
const wallet = await Wallet.findOne({
|
||||||
|
where: { resellerId }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!wallet) {
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: { transactions: [] }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const recentTransactions = await WalletTransaction.findAll({
|
||||||
|
where: { walletId: wallet.id },
|
||||||
|
limit: parseInt(limit),
|
||||||
|
order: [['createdAt', 'DESC']]
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: { transactions: recentTransactions }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get recent transactions error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to fetch recent transactions'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get usage trends
|
||||||
|
const getUsageTrends = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { period = 'month', range = 6 } = req.query;
|
||||||
|
const resellerId = req.user.resellerId;
|
||||||
|
|
||||||
|
const startDate = new Date();
|
||||||
|
startDate.setMonth(startDate.getMonth() - parseInt(range));
|
||||||
|
|
||||||
|
const usageTrends = await UsageRecord.findAll({
|
||||||
|
where: {
|
||||||
|
usageDate: { [Op.gte]: startDate }
|
||||||
|
},
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: Customer,
|
||||||
|
as: 'customer',
|
||||||
|
where: { resellerId },
|
||||||
|
attributes: []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
attributes: [
|
||||||
|
[require('sequelize').fn('DATE_TRUNC', 'month', require('sequelize').col('usage_date')), 'month'],
|
||||||
|
'metricType',
|
||||||
|
[require('sequelize').fn('SUM', require('sequelize').col('quantity')), 'totalQuantity'],
|
||||||
|
[require('sequelize').fn('SUM', require('sequelize').col('total_cost')), 'totalCost']
|
||||||
|
],
|
||||||
|
group: [
|
||||||
|
require('sequelize').fn('DATE_TRUNC', 'month', require('sequelize').col('usage_date')),
|
||||||
|
'metricType'
|
||||||
|
],
|
||||||
|
order: [
|
||||||
|
[require('sequelize').fn('DATE_TRUNC', 'month', require('sequelize').col('usage_date')), 'ASC'],
|
||||||
|
'metricType'
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: { usageTrends }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get usage trends error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to fetch usage trends'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Export dashboard data
|
||||||
|
const exportDashboardData = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { format = 'json', period = 'month' } = req.query;
|
||||||
|
const resellerId = req.user.resellerId;
|
||||||
|
|
||||||
|
// Get comprehensive dashboard data
|
||||||
|
const [overview, revenueChart, serviceDistribution, recentCustomers, recentTransactions] = await Promise.all([
|
||||||
|
getDashboardOverview(req, { json: () => {} }),
|
||||||
|
getRevenueChart(req, { json: () => {} }),
|
||||||
|
getServiceDistribution(req, { json: () => {} }),
|
||||||
|
getRecentCustomers(req, { json: () => {} }),
|
||||||
|
getRecentTransactions(req, { json: () => {} })
|
||||||
|
]);
|
||||||
|
|
||||||
|
const exportData = {
|
||||||
|
generatedAt: new Date(),
|
||||||
|
period,
|
||||||
|
resellerId,
|
||||||
|
overview,
|
||||||
|
revenueChart,
|
||||||
|
serviceDistribution,
|
||||||
|
recentCustomers,
|
||||||
|
recentTransactions
|
||||||
|
};
|
||||||
|
|
||||||
|
if (format === 'csv') {
|
||||||
|
// Convert to CSV format (simplified)
|
||||||
|
const csvData = 'Dashboard Export\n' + JSON.stringify(exportData, null, 2);
|
||||||
|
res.setHeader('Content-Type', 'text/csv');
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="dashboard-export-${Date.now()}.csv"`);
|
||||||
|
res.send(csvData);
|
||||||
|
} else {
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: exportData
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Export dashboard data error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to export dashboard data'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getDashboardOverview,
|
||||||
|
getRevenueChart,
|
||||||
|
getServiceDistribution,
|
||||||
|
getRecentCustomers,
|
||||||
|
getRecentTransactions,
|
||||||
|
getUsageTrends,
|
||||||
|
exportDashboardData
|
||||||
|
};
|
||||||
379
src/controllers/knowledgeController.js
Normal file
379
src/controllers/knowledgeController.js
Normal file
@ -0,0 +1,379 @@
|
|||||||
|
const { KnowledgeArticle, User, AuditLog } = require('../models');
|
||||||
|
const { Op } = require('sequelize');
|
||||||
|
|
||||||
|
// Get knowledge articles
|
||||||
|
const getKnowledgeArticles = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { page = 1, limit = 20, type, category, search, featured } = req.query;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
const userTier = req.user?.reseller?.tier || 'bronze';
|
||||||
|
|
||||||
|
const where = { status: 'published' };
|
||||||
|
|
||||||
|
// Filter by type and category
|
||||||
|
if (type) where.type = type;
|
||||||
|
if (category) where.category = category;
|
||||||
|
if (featured === 'true') where.featured = true;
|
||||||
|
|
||||||
|
// Search functionality
|
||||||
|
if (search) {
|
||||||
|
where[Op.or] = [
|
||||||
|
{ title: { [Op.iLike]: `%${search}%` } },
|
||||||
|
{ content: { [Op.iLike]: `%${search}%` } },
|
||||||
|
{ excerpt: { [Op.iLike]: `%${search}%` } },
|
||||||
|
{ tags: { [Op.contains]: [search] } }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Access level filtering
|
||||||
|
if (req.user) {
|
||||||
|
where[Op.or] = [
|
||||||
|
{ accessLevel: 'public' },
|
||||||
|
{ accessLevel: 'reseller_only' },
|
||||||
|
{
|
||||||
|
accessLevel: 'tier_specific',
|
||||||
|
tierAccess: { [Op.contains]: [userTier] }
|
||||||
|
}
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
where.accessLevel = 'public';
|
||||||
|
}
|
||||||
|
|
||||||
|
const { count, rows: articles } = await KnowledgeArticle.findAndCountAll({
|
||||||
|
where,
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: User,
|
||||||
|
as: 'author',
|
||||||
|
attributes: ['id', 'firstName', 'lastName']
|
||||||
|
}
|
||||||
|
],
|
||||||
|
attributes: { exclude: ['content'] }, // Exclude content from list view
|
||||||
|
limit: parseInt(limit),
|
||||||
|
offset: parseInt(offset),
|
||||||
|
order: [
|
||||||
|
['featured', 'DESC'],
|
||||||
|
['viewCount', 'DESC'],
|
||||||
|
['publishedAt', 'DESC']
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
articles,
|
||||||
|
pagination: {
|
||||||
|
total: count,
|
||||||
|
page: parseInt(page),
|
||||||
|
pages: Math.ceil(count / limit),
|
||||||
|
limit: parseInt(limit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get knowledge articles error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to fetch knowledge articles'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get article by slug
|
||||||
|
const getArticleBySlug = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { slug } = req.params;
|
||||||
|
const userTier = req.user?.reseller?.tier || 'bronze';
|
||||||
|
|
||||||
|
const article = await KnowledgeArticle.findOne({
|
||||||
|
where: { slug, status: 'published' },
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: User,
|
||||||
|
as: 'author',
|
||||||
|
attributes: ['id', 'firstName', 'lastName']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: User,
|
||||||
|
as: 'reviewer',
|
||||||
|
attributes: ['id', 'firstName', 'lastName']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!article) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Article not found'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check access permissions
|
||||||
|
if (req.user) {
|
||||||
|
if (!article.isAccessibleBy(userTier)) {
|
||||||
|
return res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Access denied for this article'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (article.accessLevel !== 'public') {
|
||||||
|
return res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Access denied for this article'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increment view count
|
||||||
|
await article.increment('viewCount');
|
||||||
|
|
||||||
|
// Get related articles
|
||||||
|
let relatedArticles = [];
|
||||||
|
if (article.relatedArticles && article.relatedArticles.length > 0) {
|
||||||
|
relatedArticles = await KnowledgeArticle.findAll({
|
||||||
|
where: {
|
||||||
|
id: { [Op.in]: article.relatedArticles },
|
||||||
|
status: 'published'
|
||||||
|
},
|
||||||
|
attributes: ['id', 'title', 'slug', 'excerpt', 'type', 'category'],
|
||||||
|
limit: 5
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
article,
|
||||||
|
relatedArticles
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get article by slug error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to fetch article'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get article categories
|
||||||
|
const getArticleCategories = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { type } = req.query;
|
||||||
|
|
||||||
|
const where = { status: 'published' };
|
||||||
|
if (type) where.type = type;
|
||||||
|
|
||||||
|
const categories = await KnowledgeArticle.findAll({
|
||||||
|
attributes: [
|
||||||
|
'category',
|
||||||
|
[require('sequelize').fn('COUNT', require('sequelize').col('id')), 'count']
|
||||||
|
],
|
||||||
|
where,
|
||||||
|
group: ['category'],
|
||||||
|
order: [['category', 'ASC']]
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: { categories }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get article categories error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to fetch article categories'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Search articles
|
||||||
|
const searchArticles = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { q: query, type, category, limit = 10 } = req.query;
|
||||||
|
const userTier = req.user?.reseller?.tier || 'bronze';
|
||||||
|
|
||||||
|
if (!query || query.trim().length < 2) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Search query must be at least 2 characters'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const where = {
|
||||||
|
status: 'published',
|
||||||
|
[Op.or] = [
|
||||||
|
{ title: { [Op.iLike]: `%${query}%` } },
|
||||||
|
{ content: { [Op.iLike]: `%${query}%` } },
|
||||||
|
{ excerpt: { [Op.iLike]: `%${query}%` } },
|
||||||
|
{ tags: { [Op.contains]: [query] } }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filter by type and category
|
||||||
|
if (type) where.type = type;
|
||||||
|
if (category) where.category = category;
|
||||||
|
|
||||||
|
// Access level filtering
|
||||||
|
if (req.user) {
|
||||||
|
where[Op.and] = [
|
||||||
|
{
|
||||||
|
[Op.or]: [
|
||||||
|
{ accessLevel: 'public' },
|
||||||
|
{ accessLevel: 'reseller_only' },
|
||||||
|
{
|
||||||
|
accessLevel: 'tier_specific',
|
||||||
|
tierAccess: { [Op.contains]: [userTier] }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
where.accessLevel = 'public';
|
||||||
|
}
|
||||||
|
|
||||||
|
const articles = await KnowledgeArticle.findAll({
|
||||||
|
where,
|
||||||
|
attributes: ['id', 'title', 'slug', 'excerpt', 'type', 'category', 'difficulty'],
|
||||||
|
limit: parseInt(limit),
|
||||||
|
order: [
|
||||||
|
['featured', 'DESC'],
|
||||||
|
['viewCount', 'DESC']
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
articles,
|
||||||
|
query,
|
||||||
|
total: articles.length
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Search articles error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to search articles'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get featured articles
|
||||||
|
const getFeaturedArticles = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { limit = 5 } = req.query;
|
||||||
|
const userTier = req.user?.reseller?.tier || 'bronze';
|
||||||
|
|
||||||
|
const where = {
|
||||||
|
status: 'published',
|
||||||
|
featured: true
|
||||||
|
};
|
||||||
|
|
||||||
|
// Access level filtering
|
||||||
|
if (req.user) {
|
||||||
|
where[Op.or] = [
|
||||||
|
{ accessLevel: 'public' },
|
||||||
|
{ accessLevel: 'reseller_only' },
|
||||||
|
{
|
||||||
|
accessLevel: 'tier_specific',
|
||||||
|
tierAccess: { [Op.contains]: [userTier] }
|
||||||
|
}
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
where.accessLevel = 'public';
|
||||||
|
}
|
||||||
|
|
||||||
|
const articles = await KnowledgeArticle.findAll({
|
||||||
|
where,
|
||||||
|
attributes: ['id', 'title', 'slug', 'excerpt', 'type', 'category', 'difficulty', 'viewCount'],
|
||||||
|
limit: parseInt(limit),
|
||||||
|
order: [['viewCount', 'DESC']]
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: { articles }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get featured articles error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to fetch featured articles'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Admin: Create knowledge article
|
||||||
|
const createKnowledgeArticle = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
excerpt,
|
||||||
|
type,
|
||||||
|
category,
|
||||||
|
subcategory,
|
||||||
|
difficulty = 'beginner',
|
||||||
|
accessLevel = 'public',
|
||||||
|
tierAccess = [],
|
||||||
|
featured = false,
|
||||||
|
tags = [],
|
||||||
|
relatedArticles = []
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// Generate slug from title
|
||||||
|
const slug = title.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9\s-]/g, '')
|
||||||
|
.replace(/\s+/g, '-')
|
||||||
|
.replace(/-+/g, '-')
|
||||||
|
.trim('-');
|
||||||
|
|
||||||
|
// Check if slug already exists
|
||||||
|
const existingArticle = await KnowledgeArticle.findOne({ where: { slug } });
|
||||||
|
if (existingArticle) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Article with this title already exists'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const article = await KnowledgeArticle.create({
|
||||||
|
title,
|
||||||
|
slug,
|
||||||
|
content,
|
||||||
|
excerpt,
|
||||||
|
type,
|
||||||
|
category,
|
||||||
|
subcategory,
|
||||||
|
difficulty,
|
||||||
|
accessLevel,
|
||||||
|
tierAccess,
|
||||||
|
featured,
|
||||||
|
tags,
|
||||||
|
relatedArticles,
|
||||||
|
createdBy: req.user.id,
|
||||||
|
status: 'draft'
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Knowledge article created successfully',
|
||||||
|
data: { article }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Create knowledge article error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to create knowledge article'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getKnowledgeArticles,
|
||||||
|
getArticleBySlug,
|
||||||
|
getArticleCategories,
|
||||||
|
searchArticles,
|
||||||
|
getFeaturedArticles,
|
||||||
|
createKnowledgeArticle
|
||||||
|
};
|
||||||
359
src/controllers/legalController.js
Normal file
359
src/controllers/legalController.js
Normal file
@ -0,0 +1,359 @@
|
|||||||
|
const { LegalDocument, LegalAcceptance, User, Reseller, AuditLog } = require('../models');
|
||||||
|
const { Op } = require('sequelize');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs').promises;
|
||||||
|
|
||||||
|
// Get all legal documents
|
||||||
|
const getLegalDocuments = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { type, status = 'active' } = req.query;
|
||||||
|
|
||||||
|
const where = {};
|
||||||
|
if (type) where.type = type;
|
||||||
|
if (status !== 'all') where.status = status;
|
||||||
|
|
||||||
|
const documents = await LegalDocument.findAll({
|
||||||
|
where,
|
||||||
|
attributes: ['id', 'title', 'type', 'version', 'status', 'effectiveDate', 'expiryDate', 'requiresAcceptance', 'acceptanceType'],
|
||||||
|
order: [['type', 'ASC'], ['createdAt', 'DESC']]
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: { documents }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get legal documents error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to fetch legal documents'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get document details
|
||||||
|
const getDocumentDetails = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { documentId } = req.params;
|
||||||
|
|
||||||
|
const document = await LegalDocument.findByPk(documentId, {
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: User,
|
||||||
|
as: 'creator',
|
||||||
|
attributes: ['id', 'firstName', 'lastName']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!document) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Document not found'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user has accepted this document
|
||||||
|
let userAcceptance = null;
|
||||||
|
if (req.user) {
|
||||||
|
userAcceptance = await LegalAcceptance.findOne({
|
||||||
|
where: {
|
||||||
|
documentId: document.id,
|
||||||
|
userId: req.user.id,
|
||||||
|
resellerId: req.user.resellerId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
document,
|
||||||
|
userAcceptance: userAcceptance ? {
|
||||||
|
id: userAcceptance.id,
|
||||||
|
acceptedAt: userAcceptance.acceptedAt,
|
||||||
|
status: userAcceptance.status,
|
||||||
|
acceptanceMethod: userAcceptance.acceptanceMethod
|
||||||
|
} : null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get document details error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to fetch document details'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Accept document
|
||||||
|
const acceptDocument = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { documentId } = req.params;
|
||||||
|
const { acceptanceMethod = 'click_through', signatureData } = req.body;
|
||||||
|
|
||||||
|
const document = await LegalDocument.findByPk(documentId);
|
||||||
|
if (!document) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Document not found'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!document.isActive()) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Document is not active'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already accepted
|
||||||
|
const existingAcceptance = await LegalAcceptance.findOne({
|
||||||
|
where: {
|
||||||
|
documentId,
|
||||||
|
userId: req.user.id,
|
||||||
|
resellerId: req.user.resellerId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingAcceptance && existingAcceptance.isValid()) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Document already accepted'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create or update acceptance
|
||||||
|
const acceptanceData = {
|
||||||
|
documentId,
|
||||||
|
userId: req.user.id,
|
||||||
|
resellerId: req.user.resellerId,
|
||||||
|
acceptanceMethod,
|
||||||
|
acceptedAt: new Date(),
|
||||||
|
ipAddress: req.ip,
|
||||||
|
userAgent: req.get('User-Agent'),
|
||||||
|
signatureData: signatureData || null,
|
||||||
|
status: 'accepted'
|
||||||
|
};
|
||||||
|
|
||||||
|
let acceptance;
|
||||||
|
if (existingAcceptance) {
|
||||||
|
acceptance = await existingAcceptance.update(acceptanceData);
|
||||||
|
} else {
|
||||||
|
acceptance = await LegalAcceptance.create(acceptanceData);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log audit
|
||||||
|
await AuditLog.create({
|
||||||
|
userId: req.user.id,
|
||||||
|
action: 'LEGAL_DOCUMENT_ACCEPTED',
|
||||||
|
resource: 'legal_document',
|
||||||
|
resourceId: document.id,
|
||||||
|
newValues: { acceptanceId: acceptance.id },
|
||||||
|
ipAddress: req.ip,
|
||||||
|
userAgent: req.get('User-Agent')
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Document accepted successfully',
|
||||||
|
data: { acceptance }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Accept document error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to accept document'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Upload compliance document
|
||||||
|
const uploadComplianceDocument = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { documentId } = req.params;
|
||||||
|
|
||||||
|
if (!req.file) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'No file uploaded'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const document = await LegalDocument.findByPk(documentId);
|
||||||
|
if (!document) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Document not found'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create acceptance with uploaded file
|
||||||
|
const acceptance = await LegalAcceptance.create({
|
||||||
|
documentId,
|
||||||
|
userId: req.user.id,
|
||||||
|
resellerId: req.user.resellerId,
|
||||||
|
acceptanceMethod: 'upload',
|
||||||
|
acceptedAt: new Date(),
|
||||||
|
ipAddress: req.ip,
|
||||||
|
userAgent: req.get('User-Agent'),
|
||||||
|
uploadedFile: req.file.filename,
|
||||||
|
status: 'pending_verification'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log audit
|
||||||
|
await AuditLog.create({
|
||||||
|
userId: req.user.id,
|
||||||
|
action: 'COMPLIANCE_DOCUMENT_UPLOADED',
|
||||||
|
resource: 'legal_document',
|
||||||
|
resourceId: document.id,
|
||||||
|
newValues: {
|
||||||
|
acceptanceId: acceptance.id,
|
||||||
|
fileName: req.file.originalname
|
||||||
|
},
|
||||||
|
ipAddress: req.ip,
|
||||||
|
userAgent: req.get('User-Agent')
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Compliance document uploaded successfully',
|
||||||
|
data: { acceptance }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Upload compliance document error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to upload compliance document'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Download document
|
||||||
|
const downloadDocument = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { documentId } = req.params;
|
||||||
|
|
||||||
|
const document = await LegalDocument.findByPk(documentId);
|
||||||
|
if (!document) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Document not found'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!document.filePath) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Document file not found'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = path.join(__dirname, '../../uploads/legal', document.filePath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.access(filePath);
|
||||||
|
} catch (error) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Document file not found on server'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', document.mimeType || 'application/octet-stream');
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="${document.title}.pdf"`);
|
||||||
|
res.sendFile(filePath);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Download document error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to download document'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get user acceptances
|
||||||
|
const getUserAcceptances = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const acceptances = await LegalAcceptance.findAll({
|
||||||
|
where: {
|
||||||
|
userId: req.user.id,
|
||||||
|
resellerId: req.user.resellerId
|
||||||
|
},
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: LegalDocument,
|
||||||
|
as: 'document',
|
||||||
|
attributes: ['id', 'title', 'type', 'version', 'requiresAcceptance']
|
||||||
|
}
|
||||||
|
],
|
||||||
|
order: [['acceptedAt', 'DESC']]
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: { acceptances }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get user acceptances error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to fetch user acceptances'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Admin: Create legal document
|
||||||
|
const createLegalDocument = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
title,
|
||||||
|
type,
|
||||||
|
version,
|
||||||
|
content,
|
||||||
|
requiresAcceptance = false,
|
||||||
|
acceptanceType = 'none',
|
||||||
|
effectiveDate,
|
||||||
|
expiryDate,
|
||||||
|
category,
|
||||||
|
tags = []
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
const document = await LegalDocument.create({
|
||||||
|
title,
|
||||||
|
type,
|
||||||
|
version,
|
||||||
|
content,
|
||||||
|
requiresAcceptance,
|
||||||
|
acceptanceType,
|
||||||
|
effectiveDate: effectiveDate ? new Date(effectiveDate) : null,
|
||||||
|
expiryDate: expiryDate ? new Date(expiryDate) : null,
|
||||||
|
category,
|
||||||
|
tags,
|
||||||
|
createdBy: req.user.id,
|
||||||
|
status: 'draft'
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Legal document created successfully',
|
||||||
|
data: { document }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Create legal document error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to create legal document'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getLegalDocuments,
|
||||||
|
getDocumentDetails,
|
||||||
|
acceptDocument,
|
||||||
|
uploadComplianceDocument,
|
||||||
|
downloadDocument,
|
||||||
|
getUserAcceptances,
|
||||||
|
createLegalDocument
|
||||||
|
};
|
||||||
333
src/controllers/marketingController.js
Normal file
333
src/controllers/marketingController.js
Normal file
@ -0,0 +1,333 @@
|
|||||||
|
const { MarketingAsset, AssetDownload, User, Reseller, AuditLog } = require('../models');
|
||||||
|
const { Op } = require('sequelize');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs').promises;
|
||||||
|
|
||||||
|
// Get marketing assets
|
||||||
|
const getMarketingAssets = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { page = 1, limit = 20, type, category, search } = req.query;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
const userTier = req.user?.reseller?.tier || 'bronze';
|
||||||
|
|
||||||
|
const where = { status: 'active' };
|
||||||
|
|
||||||
|
// Filter by type and category
|
||||||
|
if (type) where.type = type;
|
||||||
|
if (category) where.category = category;
|
||||||
|
|
||||||
|
// Search functionality
|
||||||
|
if (search) {
|
||||||
|
where[Op.or] = [
|
||||||
|
{ title: { [Op.iLike]: `%${search}%` } },
|
||||||
|
{ description: { [Op.iLike]: `%${search}%` } },
|
||||||
|
{ tags: { [Op.contains]: [search] } }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Access level filtering
|
||||||
|
where[Op.or] = [
|
||||||
|
{ accessLevel: 'public' },
|
||||||
|
{ accessLevel: 'reseller_only' },
|
||||||
|
{
|
||||||
|
accessLevel: 'tier_specific',
|
||||||
|
tierAccess: { [Op.contains]: [userTier] }
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const { count, rows: assets } = await MarketingAsset.findAndCountAll({
|
||||||
|
where,
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: User,
|
||||||
|
as: 'creator',
|
||||||
|
attributes: ['id', 'firstName', 'lastName']
|
||||||
|
}
|
||||||
|
],
|
||||||
|
limit: parseInt(limit),
|
||||||
|
offset: parseInt(offset),
|
||||||
|
order: [['createdAt', 'DESC']]
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
assets,
|
||||||
|
pagination: {
|
||||||
|
total: count,
|
||||||
|
page: parseInt(page),
|
||||||
|
pages: Math.ceil(count / limit),
|
||||||
|
limit: parseInt(limit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get marketing assets error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to fetch marketing assets'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get asset categories
|
||||||
|
const getAssetCategories = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const categories = await MarketingAsset.findAll({
|
||||||
|
attributes: [
|
||||||
|
'category',
|
||||||
|
[require('sequelize').fn('COUNT', require('sequelize').col('id')), 'count']
|
||||||
|
],
|
||||||
|
where: { status: 'active' },
|
||||||
|
group: ['category'],
|
||||||
|
order: [['category', 'ASC']]
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: { categories }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get asset categories error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to fetch asset categories'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Download asset
|
||||||
|
const downloadAsset = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { assetId } = req.params;
|
||||||
|
const { purpose } = req.body;
|
||||||
|
const userTier = req.user?.reseller?.tier || 'bronze';
|
||||||
|
|
||||||
|
const asset = await MarketingAsset.findByPk(assetId);
|
||||||
|
if (!asset) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Asset not found'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check access permissions
|
||||||
|
if (!asset.isAccessibleBy(userTier)) {
|
||||||
|
return res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Access denied for this asset'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if asset is expired
|
||||||
|
if (asset.isExpired()) {
|
||||||
|
return res.status(410).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Asset has expired'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = path.join(__dirname, '../../uploads/marketing', asset.filePath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.access(filePath);
|
||||||
|
} catch (error) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Asset file not found on server'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log download
|
||||||
|
await AssetDownload.create({
|
||||||
|
assetId: asset.id,
|
||||||
|
userId: req.user.id,
|
||||||
|
resellerId: req.user.resellerId,
|
||||||
|
ipAddress: req.ip,
|
||||||
|
userAgent: req.get('User-Agent'),
|
||||||
|
purpose
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update download count
|
||||||
|
await asset.increment('downloadCount');
|
||||||
|
|
||||||
|
// Log audit
|
||||||
|
await AuditLog.create({
|
||||||
|
userId: req.user.id,
|
||||||
|
action: 'MARKETING_ASSET_DOWNLOADED',
|
||||||
|
resource: 'marketing_asset',
|
||||||
|
resourceId: asset.id,
|
||||||
|
newValues: { purpose },
|
||||||
|
ipAddress: req.ip,
|
||||||
|
userAgent: req.get('User-Agent')
|
||||||
|
});
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', asset.mimeType);
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="${asset.fileName}"`);
|
||||||
|
res.sendFile(filePath);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Download asset error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to download asset'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get asset details
|
||||||
|
const getAssetDetails = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { assetId } = req.params;
|
||||||
|
const userTier = req.user?.reseller?.tier || 'bronze';
|
||||||
|
|
||||||
|
const asset = await MarketingAsset.findByPk(assetId, {
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: User,
|
||||||
|
as: 'creator',
|
||||||
|
attributes: ['id', 'firstName', 'lastName']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: User,
|
||||||
|
as: 'updater',
|
||||||
|
attributes: ['id', 'firstName', 'lastName']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!asset) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Asset not found'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check access permissions
|
||||||
|
if (!asset.isAccessibleBy(userTier)) {
|
||||||
|
return res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Access denied for this asset'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: { asset }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get asset details error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to fetch asset details'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get download history
|
||||||
|
const getDownloadHistory = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { page = 1, limit = 20 } = req.query;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
const { count, rows: downloads } = await AssetDownload.findAndCountAll({
|
||||||
|
where: {
|
||||||
|
userId: req.user.id,
|
||||||
|
resellerId: req.user.resellerId
|
||||||
|
},
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: MarketingAsset,
|
||||||
|
as: 'asset',
|
||||||
|
attributes: ['id', 'title', 'type', 'category', 'fileName']
|
||||||
|
}
|
||||||
|
],
|
||||||
|
limit: parseInt(limit),
|
||||||
|
offset: parseInt(offset),
|
||||||
|
order: [['downloadedAt', 'DESC']]
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
downloads,
|
||||||
|
pagination: {
|
||||||
|
total: count,
|
||||||
|
page: parseInt(page),
|
||||||
|
pages: Math.ceil(count / limit),
|
||||||
|
limit: parseInt(limit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get download history error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to fetch download history'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Admin: Create marketing asset
|
||||||
|
const createMarketingAsset = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
type,
|
||||||
|
category,
|
||||||
|
accessLevel = 'reseller_only',
|
||||||
|
tierAccess = [],
|
||||||
|
isEditable = false,
|
||||||
|
editableFormat,
|
||||||
|
tags = [],
|
||||||
|
expiryDate
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
if (!req.file) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'No file uploaded'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const asset = await MarketingAsset.create({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
type,
|
||||||
|
category,
|
||||||
|
filePath: req.file.filename,
|
||||||
|
fileName: req.file.originalname,
|
||||||
|
fileSize: req.file.size,
|
||||||
|
mimeType: req.file.mimetype,
|
||||||
|
accessLevel,
|
||||||
|
tierAccess,
|
||||||
|
isEditable,
|
||||||
|
editableFormat,
|
||||||
|
tags,
|
||||||
|
expiryDate: expiryDate ? new Date(expiryDate) : null,
|
||||||
|
createdBy: req.user.id,
|
||||||
|
status: 'active'
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Marketing asset created successfully',
|
||||||
|
data: { asset }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Create marketing asset error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to create marketing asset'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getMarketingAssets,
|
||||||
|
getAssetCategories,
|
||||||
|
downloadAsset,
|
||||||
|
getAssetDetails,
|
||||||
|
getDownloadHistory,
|
||||||
|
createMarketingAsset
|
||||||
|
};
|
||||||
399
src/controllers/ordersController.js
Normal file
399
src/controllers/ordersController.js
Normal file
@ -0,0 +1,399 @@
|
|||||||
|
const { Order, OrderItem, Customer, Product, Reseller, Invoice, AuditLog } = require('../models');
|
||||||
|
const { Op } = require('sequelize');
|
||||||
|
|
||||||
|
// Get all orders for reseller
|
||||||
|
const getOrders = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { page = 1, limit = 20, status, customerId, startDate, endDate } = req.query;
|
||||||
|
const resellerId = req.user.resellerId;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
const where = { resellerId };
|
||||||
|
|
||||||
|
if (status) where.status = status;
|
||||||
|
if (customerId) where.customerId = customerId;
|
||||||
|
if (startDate && endDate) {
|
||||||
|
where.createdAt = {
|
||||||
|
[Op.between]: [new Date(startDate), new Date(endDate)]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const { count, rows: orders } = await Order.findAndCountAll({
|
||||||
|
where,
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: Customer,
|
||||||
|
as: 'customer',
|
||||||
|
attributes: ['id', 'companyName', 'contactPerson', 'email']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: OrderItem,
|
||||||
|
as: 'items',
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: Product,
|
||||||
|
as: 'product',
|
||||||
|
attributes: ['id', 'name', 'category', 'sku']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
limit: parseInt(limit),
|
||||||
|
offset: parseInt(offset),
|
||||||
|
order: [['createdAt', 'DESC']]
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
orders,
|
||||||
|
pagination: {
|
||||||
|
total: count,
|
||||||
|
page: parseInt(page),
|
||||||
|
pages: Math.ceil(count / limit),
|
||||||
|
limit: parseInt(limit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get orders error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to fetch orders'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get order details
|
||||||
|
const getOrderDetails = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { orderId } = req.params;
|
||||||
|
const resellerId = req.user.resellerId;
|
||||||
|
|
||||||
|
const order = await Order.findOne({
|
||||||
|
where: { id: orderId, resellerId },
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: Customer,
|
||||||
|
as: 'customer',
|
||||||
|
attributes: ['id', 'companyName', 'contactPerson', 'email', 'phone', 'address']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: OrderItem,
|
||||||
|
as: 'items',
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: Product,
|
||||||
|
as: 'product',
|
||||||
|
attributes: ['id', 'name', 'description', 'category', 'sku', 'basePrice']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: Invoice,
|
||||||
|
as: 'invoices',
|
||||||
|
attributes: ['id', 'invoiceNumber', 'status', 'totalAmount', 'issueDate', 'dueDate']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!order) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Order not found'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: { order }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get order details error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to fetch order details'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create new order
|
||||||
|
const createOrder = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { customerId, items, notes, type = 'new_service' } = req.body;
|
||||||
|
const resellerId = req.user.resellerId;
|
||||||
|
|
||||||
|
// Verify customer belongs to reseller
|
||||||
|
const customer = await Customer.findOne({
|
||||||
|
where: { id: customerId, resellerId }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!customer) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Customer not found'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate order number
|
||||||
|
const orderCount = await Order.count({ where: { resellerId } });
|
||||||
|
const orderNumber = `ORD-${resellerId.slice(-8).toUpperCase()}-${String(orderCount + 1).padStart(4, '0')}`;
|
||||||
|
|
||||||
|
// Calculate totals
|
||||||
|
let subtotal = 0;
|
||||||
|
const orderItems = [];
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const product = await Product.findByPk(item.productId);
|
||||||
|
if (!product) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: `Product ${item.productId} not found`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get reseller pricing
|
||||||
|
const reseller = await Reseller.findByPk(resellerId);
|
||||||
|
const unitPrice = product.calculateResellerPrice(reseller.tier, item.customPrice);
|
||||||
|
const totalPrice = unitPrice * item.quantity;
|
||||||
|
|
||||||
|
orderItems.push({
|
||||||
|
productId: item.productId,
|
||||||
|
quantity: item.quantity,
|
||||||
|
unitPrice,
|
||||||
|
totalPrice,
|
||||||
|
configuration: item.configuration || {},
|
||||||
|
specifications: item.specifications || {}
|
||||||
|
});
|
||||||
|
|
||||||
|
subtotal += totalPrice;
|
||||||
|
}
|
||||||
|
|
||||||
|
const taxAmount = subtotal * 0.18; // 18% GST
|
||||||
|
const totalAmount = subtotal + taxAmount;
|
||||||
|
|
||||||
|
// Create order
|
||||||
|
const order = await Order.create({
|
||||||
|
orderNumber,
|
||||||
|
resellerId,
|
||||||
|
customerId,
|
||||||
|
type,
|
||||||
|
subtotal,
|
||||||
|
taxAmount,
|
||||||
|
totalAmount,
|
||||||
|
notes,
|
||||||
|
status: 'pending'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create order items
|
||||||
|
for (const item of orderItems) {
|
||||||
|
await OrderItem.create({
|
||||||
|
orderId: order.id,
|
||||||
|
...item
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log audit
|
||||||
|
await AuditLog.create({
|
||||||
|
userId: req.user.id,
|
||||||
|
action: 'ORDER_CREATED',
|
||||||
|
resource: 'order',
|
||||||
|
resourceId: order.id,
|
||||||
|
newValues: order.toJSON(),
|
||||||
|
ipAddress: req.ip,
|
||||||
|
userAgent: req.get('User-Agent')
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch complete order with items
|
||||||
|
const completeOrder = await Order.findByPk(order.id, {
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: Customer,
|
||||||
|
as: 'customer',
|
||||||
|
attributes: ['id', 'companyName', 'contactPerson']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: OrderItem,
|
||||||
|
as: 'items',
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: Product,
|
||||||
|
as: 'product',
|
||||||
|
attributes: ['id', 'name', 'sku']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Order created successfully',
|
||||||
|
data: { order: completeOrder }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Create order error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to create order'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update order status
|
||||||
|
const updateOrderStatus = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { orderId } = req.params;
|
||||||
|
const { status, notes } = req.body;
|
||||||
|
const resellerId = req.user.resellerId;
|
||||||
|
|
||||||
|
const order = await Order.findOne({
|
||||||
|
where: { id: orderId, resellerId }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!order) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Order not found'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldStatus = order.status;
|
||||||
|
await order.update({
|
||||||
|
status,
|
||||||
|
notes: notes || order.notes
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log audit
|
||||||
|
await AuditLog.create({
|
||||||
|
userId: req.user.id,
|
||||||
|
action: 'ORDER_STATUS_UPDATED',
|
||||||
|
resource: 'order',
|
||||||
|
resourceId: order.id,
|
||||||
|
oldValues: { status: oldStatus },
|
||||||
|
newValues: { status },
|
||||||
|
ipAddress: req.ip,
|
||||||
|
userAgent: req.get('User-Agent')
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Order status updated successfully',
|
||||||
|
data: { order }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Update order status error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to update order status'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get pending orders
|
||||||
|
const getPendingOrders = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const resellerId = req.user.resellerId;
|
||||||
|
|
||||||
|
const pendingOrders = await Order.findAll({
|
||||||
|
where: {
|
||||||
|
resellerId,
|
||||||
|
status: ['pending', 'draft']
|
||||||
|
},
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: Customer,
|
||||||
|
as: 'customer',
|
||||||
|
attributes: ['id', 'companyName', 'contactPerson']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: OrderItem,
|
||||||
|
as: 'items',
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: Product,
|
||||||
|
as: 'product',
|
||||||
|
attributes: ['id', 'name', 'category']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
order: [['createdAt', 'DESC']]
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: { orders: pendingOrders }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get pending orders error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to fetch pending orders'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cancel order
|
||||||
|
const cancelOrder = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { orderId } = req.params;
|
||||||
|
const { reason } = req.body;
|
||||||
|
const resellerId = req.user.resellerId;
|
||||||
|
|
||||||
|
const order = await Order.findOne({
|
||||||
|
where: { id: orderId, resellerId }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!order) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Order not found'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!['draft', 'pending'].includes(order.status)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Order cannot be cancelled in current status'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await order.update({
|
||||||
|
status: 'cancelled',
|
||||||
|
notes: `${order.notes || ''}\nCancellation reason: ${reason}`
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log audit
|
||||||
|
await AuditLog.create({
|
||||||
|
userId: req.user.id,
|
||||||
|
action: 'ORDER_CANCELLED',
|
||||||
|
resource: 'order',
|
||||||
|
resourceId: order.id,
|
||||||
|
newValues: { status: 'cancelled', reason },
|
||||||
|
ipAddress: req.ip,
|
||||||
|
userAgent: req.get('User-Agent')
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Order cancelled successfully',
|
||||||
|
data: { order }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Cancel order error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to cancel order'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getOrders,
|
||||||
|
getOrderDetails,
|
||||||
|
createOrder,
|
||||||
|
updateOrderStatus,
|
||||||
|
getPendingOrders,
|
||||||
|
cancelOrder
|
||||||
|
};
|
||||||
229
src/controllers/productController.js
Normal file
229
src/controllers/productController.js
Normal file
@ -0,0 +1,229 @@
|
|||||||
|
const { Product, ResellerPricing, Reseller, AuditLog } = require('../models');
|
||||||
|
const { Op } = require('sequelize');
|
||||||
|
|
||||||
|
// Get all products with reseller-specific pricing
|
||||||
|
const getProducts = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { page = 1, limit = 20, category, status, search } = req.query;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
const where = { status: status || 'active' };
|
||||||
|
if (category) where.category = category;
|
||||||
|
if (search) {
|
||||||
|
where[Op.or] = [
|
||||||
|
{ name: { [Op.iLike]: `%${search}%` } },
|
||||||
|
{ description: { [Op.iLike]: `%${search}%` } },
|
||||||
|
{ sku: { [Op.iLike]: `%${search}%` } }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const { count, rows: products } = await Product.findAndCountAll({
|
||||||
|
where,
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: ResellerPricing,
|
||||||
|
as: 'resellerPricing',
|
||||||
|
where: { resellerId: req.user.resellerId, isActive: true },
|
||||||
|
required: false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
limit: parseInt(limit),
|
||||||
|
offset: parseInt(offset),
|
||||||
|
order: [['name', 'ASC']]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate pricing for each product
|
||||||
|
const reseller = await Reseller.findByPk(req.user.resellerId);
|
||||||
|
const productsWithPricing = products.map(product => {
|
||||||
|
const productData = product.toJSON();
|
||||||
|
|
||||||
|
// Get custom pricing if exists
|
||||||
|
const customPricing = productData.resellerPricing?.[0];
|
||||||
|
|
||||||
|
if (customPricing) {
|
||||||
|
productData.finalPrice = customPricing.calculateFinalPrice(productData.basePrice);
|
||||||
|
productData.margin = customPricing.customMargin;
|
||||||
|
productData.pricingType = customPricing.pricingType;
|
||||||
|
} else {
|
||||||
|
// Use tier-based pricing
|
||||||
|
productData.finalPrice = product.calculateResellerPrice(reseller.tier);
|
||||||
|
productData.margin = product.getMarginForTier(reseller.tier);
|
||||||
|
productData.pricingType = 'tier_based';
|
||||||
|
}
|
||||||
|
|
||||||
|
productData.profit = productData.finalPrice - productData.basePrice;
|
||||||
|
productData.profitMargin = ((productData.profit / productData.basePrice) * 100).toFixed(2);
|
||||||
|
|
||||||
|
return productData;
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
products: productsWithPricing,
|
||||||
|
pagination: {
|
||||||
|
total: count,
|
||||||
|
page: parseInt(page),
|
||||||
|
pages: Math.ceil(count / limit),
|
||||||
|
limit: parseInt(limit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get products error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to fetch products'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get product details
|
||||||
|
const getProduct = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { productId } = req.params;
|
||||||
|
|
||||||
|
const product = await Product.findByPk(productId, {
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: ResellerPricing,
|
||||||
|
as: 'resellerPricing',
|
||||||
|
where: { resellerId: req.user.resellerId, isActive: true },
|
||||||
|
required: false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!product) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Product not found'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const reseller = await Reseller.findByPk(req.user.resellerId);
|
||||||
|
const productData = product.toJSON();
|
||||||
|
|
||||||
|
// Calculate pricing
|
||||||
|
const customPricing = productData.resellerPricing?.[0];
|
||||||
|
|
||||||
|
if (customPricing) {
|
||||||
|
productData.finalPrice = customPricing.calculateFinalPrice(productData.basePrice);
|
||||||
|
productData.margin = customPricing.customMargin;
|
||||||
|
productData.pricingType = customPricing.pricingType;
|
||||||
|
} else {
|
||||||
|
productData.finalPrice = product.calculateResellerPrice(reseller.tier);
|
||||||
|
productData.margin = product.getMarginForTier(reseller.tier);
|
||||||
|
productData.pricingType = 'tier_based';
|
||||||
|
}
|
||||||
|
|
||||||
|
productData.profit = productData.finalPrice - productData.basePrice;
|
||||||
|
productData.profitMargin = ((productData.profit / productData.basePrice) * 100).toFixed(2);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: { product: productData }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get product error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to fetch product'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update custom pricing for a product
|
||||||
|
const updateProductPricing = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { productId } = req.params;
|
||||||
|
const { customMargin, customPrice, pricingType = 'margin' } = req.body;
|
||||||
|
|
||||||
|
const product = await Product.findByPk(productId);
|
||||||
|
if (!product) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Product not found'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if custom pricing already exists
|
||||||
|
let resellerPricing = await ResellerPricing.findOne({
|
||||||
|
where: {
|
||||||
|
resellerId: req.user.resellerId,
|
||||||
|
productId: productId,
|
||||||
|
isActive: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const pricingData = {
|
||||||
|
resellerId: req.user.resellerId,
|
||||||
|
productId: productId,
|
||||||
|
pricingType,
|
||||||
|
customMargin: pricingType === 'margin' ? customMargin : null,
|
||||||
|
customPrice: pricingType === 'fixed_price' ? customPrice : null,
|
||||||
|
isActive: true,
|
||||||
|
effectiveFrom: new Date()
|
||||||
|
};
|
||||||
|
|
||||||
|
if (resellerPricing) {
|
||||||
|
await resellerPricing.update(pricingData);
|
||||||
|
} else {
|
||||||
|
resellerPricing = await ResellerPricing.create(pricingData);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log audit
|
||||||
|
await AuditLog.create({
|
||||||
|
userId: req.user.id,
|
||||||
|
action: 'PRODUCT_PRICING_UPDATED',
|
||||||
|
resource: 'reseller_pricing',
|
||||||
|
resourceId: resellerPricing.id,
|
||||||
|
newValues: pricingData,
|
||||||
|
ipAddress: req.ip,
|
||||||
|
userAgent: req.get('User-Agent')
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Product pricing updated successfully',
|
||||||
|
data: { resellerPricing }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Update product pricing error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to update product pricing'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get product categories
|
||||||
|
const getCategories = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const categories = await Product.findAll({
|
||||||
|
attributes: ['category'],
|
||||||
|
group: ['category'],
|
||||||
|
where: { status: 'active' }
|
||||||
|
});
|
||||||
|
|
||||||
|
const categoryList = categories.map(item => item.category);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: { categories: categoryList }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get categories error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to fetch categories'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getProducts,
|
||||||
|
getProduct,
|
||||||
|
updateProductPricing,
|
||||||
|
getCategories
|
||||||
|
};
|
||||||
356
src/controllers/provisioningController.js
Normal file
356
src/controllers/provisioningController.js
Normal file
@ -0,0 +1,356 @@
|
|||||||
|
const { Instance, InstanceSnapshot, InstanceEvent, Customer, Product, AuditLog } = require('../models');
|
||||||
|
const { Op } = require('sequelize');
|
||||||
|
|
||||||
|
// Get all instances for reseller
|
||||||
|
const getInstances = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { page = 1, limit = 20, status, type, customerId, region } = req.query;
|
||||||
|
const resellerId = req.user.resellerId;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
const where = { resellerId };
|
||||||
|
if (status) where.status = status;
|
||||||
|
if (type) where.type = type;
|
||||||
|
if (customerId) where.customerId = customerId;
|
||||||
|
if (region) where.region = region;
|
||||||
|
|
||||||
|
const { count, rows: instances } = await Instance.findAndCountAll({
|
||||||
|
where,
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: Customer,
|
||||||
|
as: 'customer',
|
||||||
|
attributes: ['id', 'companyName', 'contactPerson']
|
||||||
|
}
|
||||||
|
],
|
||||||
|
limit: parseInt(limit),
|
||||||
|
offset: parseInt(offset),
|
||||||
|
order: [['createdAt', 'DESC']]
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
instances,
|
||||||
|
pagination: {
|
||||||
|
total: count,
|
||||||
|
page: parseInt(page),
|
||||||
|
pages: Math.ceil(count / limit),
|
||||||
|
limit: parseInt(limit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get instances error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to fetch instances'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create new instance
|
||||||
|
const createInstance = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
customerId,
|
||||||
|
name,
|
||||||
|
template,
|
||||||
|
size,
|
||||||
|
region,
|
||||||
|
zone,
|
||||||
|
specifications,
|
||||||
|
configuration = {},
|
||||||
|
tags = []
|
||||||
|
} = req.body;
|
||||||
|
const resellerId = req.user.resellerId;
|
||||||
|
|
||||||
|
// Verify customer belongs to reseller
|
||||||
|
const customer = await Customer.findOne({
|
||||||
|
where: { id: customerId, resellerId }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!customer) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Customer not found'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create instance record
|
||||||
|
const instance = await Instance.create({
|
||||||
|
customerId,
|
||||||
|
resellerId,
|
||||||
|
name,
|
||||||
|
template,
|
||||||
|
size,
|
||||||
|
region,
|
||||||
|
zone,
|
||||||
|
specifications,
|
||||||
|
configuration,
|
||||||
|
tags,
|
||||||
|
status: 'pending'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log creation event
|
||||||
|
await InstanceEvent.create({
|
||||||
|
instanceId: instance.id,
|
||||||
|
eventType: 'created',
|
||||||
|
status: 'completed',
|
||||||
|
message: 'Instance created successfully',
|
||||||
|
triggeredBy: req.user.id,
|
||||||
|
source: 'user',
|
||||||
|
startedAt: new Date(),
|
||||||
|
completedAt: new Date()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log audit
|
||||||
|
await AuditLog.create({
|
||||||
|
userId: req.user.id,
|
||||||
|
action: 'INSTANCE_CREATED',
|
||||||
|
resource: 'instance',
|
||||||
|
resourceId: instance.id,
|
||||||
|
newValues: instance.toJSON(),
|
||||||
|
ipAddress: req.ip,
|
||||||
|
userAgent: req.get('User-Agent')
|
||||||
|
});
|
||||||
|
|
||||||
|
// TODO: Integrate with Cloudtopiaa IaaS API to actually provision the instance
|
||||||
|
// This would be an async operation that updates the instance status
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Instance creation initiated',
|
||||||
|
data: { instance }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Create instance error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to create instance'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Start instance
|
||||||
|
const startInstance = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { instanceId } = req.params;
|
||||||
|
const resellerId = req.user.resellerId;
|
||||||
|
|
||||||
|
const instance = await Instance.findOne({
|
||||||
|
where: { id: instanceId, resellerId }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!instance) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Instance not found'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!instance.canStart()) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Instance cannot be started in current state'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update instance status
|
||||||
|
await instance.update({ status: 'starting' });
|
||||||
|
|
||||||
|
// Log event
|
||||||
|
await InstanceEvent.create({
|
||||||
|
instanceId: instance.id,
|
||||||
|
eventType: 'started',
|
||||||
|
status: 'in_progress',
|
||||||
|
message: 'Starting instance',
|
||||||
|
triggeredBy: req.user.id,
|
||||||
|
source: 'user',
|
||||||
|
startedAt: new Date()
|
||||||
|
});
|
||||||
|
|
||||||
|
// TODO: Call Cloudtopiaa IaaS API to start the instance
|
||||||
|
// This would be an async operation
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Instance start initiated',
|
||||||
|
data: { instance }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Start instance error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to start instance'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Stop instance
|
||||||
|
const stopInstance = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { instanceId } = req.params;
|
||||||
|
const resellerId = req.user.resellerId;
|
||||||
|
|
||||||
|
const instance = await Instance.findOne({
|
||||||
|
where: { id: instanceId, resellerId }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!instance) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Instance not found'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!instance.canStop()) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Instance cannot be stopped in current state'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update instance status
|
||||||
|
await instance.update({ status: 'stopping' });
|
||||||
|
|
||||||
|
// Log event
|
||||||
|
await InstanceEvent.create({
|
||||||
|
instanceId: instance.id,
|
||||||
|
eventType: 'stopped',
|
||||||
|
status: 'in_progress',
|
||||||
|
message: 'Stopping instance',
|
||||||
|
triggeredBy: req.user.id,
|
||||||
|
source: 'user',
|
||||||
|
startedAt: new Date()
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Instance stop initiated',
|
||||||
|
data: { instance }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Stop instance error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to stop instance'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create snapshot
|
||||||
|
const createSnapshot = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { instanceId } = req.params;
|
||||||
|
const { name, description, type = 'manual' } = req.body;
|
||||||
|
const resellerId = req.user.resellerId;
|
||||||
|
|
||||||
|
const instance = await Instance.findOne({
|
||||||
|
where: { id: instanceId, resellerId }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!instance) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Instance not found'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create snapshot record
|
||||||
|
const snapshot = await InstanceSnapshot.create({
|
||||||
|
instanceId,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
type,
|
||||||
|
status: 'creating'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log event
|
||||||
|
await InstanceEvent.create({
|
||||||
|
instanceId: instance.id,
|
||||||
|
eventType: 'snapshot_created',
|
||||||
|
status: 'in_progress',
|
||||||
|
message: `Creating snapshot: ${name}`,
|
||||||
|
triggeredBy: req.user.id,
|
||||||
|
source: 'user',
|
||||||
|
startedAt: new Date()
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Snapshot creation initiated',
|
||||||
|
data: { snapshot }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Create snapshot error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to create snapshot'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get instance events
|
||||||
|
const getInstanceEvents = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { instanceId } = req.params;
|
||||||
|
const { page = 1, limit = 20 } = req.query;
|
||||||
|
const resellerId = req.user.resellerId;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
// Verify instance belongs to reseller
|
||||||
|
const instance = await Instance.findOne({
|
||||||
|
where: { id: instanceId, resellerId }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!instance) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Instance not found'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { count, rows: events } = await InstanceEvent.findAndCountAll({
|
||||||
|
where: { instanceId },
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: require('../models').User,
|
||||||
|
as: 'user',
|
||||||
|
attributes: ['id', 'firstName', 'lastName']
|
||||||
|
}
|
||||||
|
],
|
||||||
|
limit: parseInt(limit),
|
||||||
|
offset: parseInt(offset),
|
||||||
|
order: [['createdAt', 'DESC']]
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
events,
|
||||||
|
pagination: {
|
||||||
|
total: count,
|
||||||
|
page: parseInt(page),
|
||||||
|
pages: Math.ceil(count / limit),
|
||||||
|
limit: parseInt(limit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get instance events error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to fetch instance events'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getInstances,
|
||||||
|
createInstance,
|
||||||
|
startInstance,
|
||||||
|
stopInstance,
|
||||||
|
createSnapshot,
|
||||||
|
getInstanceEvents
|
||||||
|
};
|
||||||
279
src/controllers/reportsController.js
Normal file
279
src/controllers/reportsController.js
Normal file
@ -0,0 +1,279 @@
|
|||||||
|
const { Customer, CustomerService, UsageRecord, Invoice, WalletTransaction, Wallet, Product, Commission } = require('../models');
|
||||||
|
const { Op } = require('sequelize');
|
||||||
|
|
||||||
|
// Get sales report
|
||||||
|
const getSalesReport = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { startDate, endDate, groupBy = 'month', format = 'json' } = req.query;
|
||||||
|
const resellerId = req.user.resellerId;
|
||||||
|
|
||||||
|
const dateFilter = {};
|
||||||
|
if (startDate && endDate) {
|
||||||
|
dateFilter.createdAt = {
|
||||||
|
[Op.between]: [new Date(startDate), new Date(endDate)]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sales by period
|
||||||
|
const salesData = await Invoice.findAll({
|
||||||
|
where: {
|
||||||
|
resellerId,
|
||||||
|
status: 'paid',
|
||||||
|
...dateFilter
|
||||||
|
},
|
||||||
|
attributes: [
|
||||||
|
[require('sequelize').fn('DATE_TRUNC', groupBy, require('sequelize').col('paid_at')), 'period'],
|
||||||
|
[require('sequelize').fn('SUM', require('sequelize').col('total_amount')), 'totalSales'],
|
||||||
|
[require('sequelize').fn('COUNT', require('sequelize').col('id')), 'invoiceCount'],
|
||||||
|
[require('sequelize').fn('AVG', require('sequelize').col('total_amount')), 'averageInvoice']
|
||||||
|
],
|
||||||
|
group: [require('sequelize').fn('DATE_TRUNC', groupBy, require('sequelize').col('paid_at'))],
|
||||||
|
order: [[require('sequelize').fn('DATE_TRUNC', groupBy, require('sequelize').col('paid_at')), 'ASC']]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Top customers by revenue
|
||||||
|
const topCustomers = await Invoice.findAll({
|
||||||
|
where: {
|
||||||
|
resellerId,
|
||||||
|
status: 'paid',
|
||||||
|
...dateFilter
|
||||||
|
},
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: Customer,
|
||||||
|
as: 'customer',
|
||||||
|
attributes: ['id', 'companyName', 'contactPerson']
|
||||||
|
}
|
||||||
|
],
|
||||||
|
attributes: [
|
||||||
|
'customerId',
|
||||||
|
[require('sequelize').fn('SUM', require('sequelize').col('total_amount')), 'totalRevenue'],
|
||||||
|
[require('sequelize').fn('COUNT', require('sequelize').col('Invoice.id')), 'invoiceCount']
|
||||||
|
],
|
||||||
|
group: ['customerId', 'customer.id', 'customer.company_name', 'customer.contact_person'],
|
||||||
|
order: [[require('sequelize').fn('SUM', require('sequelize').col('total_amount')), 'DESC']],
|
||||||
|
limit: 10
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
salesData,
|
||||||
|
topCustomers,
|
||||||
|
reportPeriod: { startDate, endDate },
|
||||||
|
generatedAt: new Date()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get sales report error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to generate sales report'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get usage report
|
||||||
|
const getUsageReport = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { startDate, endDate, customerId, productId } = req.query;
|
||||||
|
const resellerId = req.user.resellerId;
|
||||||
|
|
||||||
|
const where = {};
|
||||||
|
if (startDate && endDate) {
|
||||||
|
where.usageDate = {
|
||||||
|
[Op.between]: [new Date(startDate), new Date(endDate)]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const customerWhere = { resellerId };
|
||||||
|
if (customerId) {
|
||||||
|
customerWhere.id = customerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (productId) {
|
||||||
|
where.productId = productId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const usageData = await UsageRecord.findAll({
|
||||||
|
where,
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: Customer,
|
||||||
|
as: 'customer',
|
||||||
|
where: customerWhere,
|
||||||
|
attributes: ['id', 'companyName']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: Product,
|
||||||
|
as: 'product',
|
||||||
|
attributes: ['id', 'name', 'category']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: CustomerService,
|
||||||
|
as: 'service',
|
||||||
|
attributes: ['id', 'serviceName']
|
||||||
|
}
|
||||||
|
],
|
||||||
|
order: [['usageDate', 'DESC']]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Aggregate by customer and product
|
||||||
|
const usageSummary = usageData.reduce((acc, record) => {
|
||||||
|
const key = `${record.customerId}_${record.productId}`;
|
||||||
|
if (!acc[key]) {
|
||||||
|
acc[key] = {
|
||||||
|
customer: record.customer,
|
||||||
|
product: record.product,
|
||||||
|
totalQuantity: 0,
|
||||||
|
totalCost: 0,
|
||||||
|
records: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
acc[key].totalQuantity += parseFloat(record.quantity);
|
||||||
|
acc[key].totalCost += parseFloat(record.totalCost);
|
||||||
|
acc[key].records.push(record);
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
usageData,
|
||||||
|
usageSummary: Object.values(usageSummary),
|
||||||
|
reportPeriod: { startDate, endDate },
|
||||||
|
generatedAt: new Date()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get usage report error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to generate usage report'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get commission report
|
||||||
|
const getCommissionReport = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { startDate, endDate, status = 'all' } = req.query;
|
||||||
|
const resellerId = req.user.resellerId;
|
||||||
|
|
||||||
|
const where = { resellerId };
|
||||||
|
if (startDate && endDate) {
|
||||||
|
where.createdAt = {
|
||||||
|
[Op.between]: [new Date(startDate), new Date(endDate)]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (status !== 'all') {
|
||||||
|
where.status = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
const commissions = await Commission.findAll({
|
||||||
|
where,
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: Customer,
|
||||||
|
as: 'customer',
|
||||||
|
attributes: ['id', 'companyName']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: Invoice,
|
||||||
|
as: 'invoice',
|
||||||
|
attributes: ['id', 'invoiceNumber', 'totalAmount']
|
||||||
|
}
|
||||||
|
],
|
||||||
|
order: [['createdAt', 'DESC']]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate totals
|
||||||
|
const totals = commissions.reduce((acc, commission) => {
|
||||||
|
acc.totalCommission += parseFloat(commission.commissionAmount);
|
||||||
|
acc.totalBase += parseFloat(commission.baseAmount);
|
||||||
|
|
||||||
|
if (commission.status === 'paid') {
|
||||||
|
acc.paidCommission += parseFloat(commission.commissionAmount);
|
||||||
|
} else if (commission.status === 'pending') {
|
||||||
|
acc.pendingCommission += parseFloat(commission.commissionAmount);
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
}, {
|
||||||
|
totalCommission: 0,
|
||||||
|
totalBase: 0,
|
||||||
|
paidCommission: 0,
|
||||||
|
pendingCommission: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
commissions,
|
||||||
|
totals,
|
||||||
|
reportPeriod: { startDate, endDate },
|
||||||
|
generatedAt: new Date()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get commission report error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to generate commission report'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Export report data
|
||||||
|
const exportReport = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { reportType, format = 'csv', ...params } = req.query;
|
||||||
|
|
||||||
|
let reportData;
|
||||||
|
switch (reportType) {
|
||||||
|
case 'sales':
|
||||||
|
reportData = await getSalesReport({ ...req, query: params }, { json: () => {} });
|
||||||
|
break;
|
||||||
|
case 'usage':
|
||||||
|
reportData = await getUsageReport({ ...req, query: params }, { json: () => {} });
|
||||||
|
break;
|
||||||
|
case 'commission':
|
||||||
|
reportData = await getCommissionReport({ ...req, query: params }, { json: () => {} });
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Invalid report type'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (format === 'csv') {
|
||||||
|
// Convert to CSV (simplified implementation)
|
||||||
|
const csvData = `${reportType.toUpperCase()} Report\nGenerated: ${new Date()}\n\n${JSON.stringify(reportData, null, 2)}`;
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', 'text/csv');
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="${reportType}-report-${Date.now()}.csv"`);
|
||||||
|
res.send(csvData);
|
||||||
|
} else {
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: reportData
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Export report error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to export report'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getSalesReport,
|
||||||
|
getUsageReport,
|
||||||
|
getCommissionReport,
|
||||||
|
exportReport
|
||||||
|
};
|
||||||
421
src/controllers/trainingController.js
Normal file
421
src/controllers/trainingController.js
Normal file
@ -0,0 +1,421 @@
|
|||||||
|
const { Course, CourseEnrollment, Certificate, User, Reseller, AuditLog } = require('../models');
|
||||||
|
const { Op } = require('sequelize');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
// Get available courses
|
||||||
|
const getCourses = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { page = 1, limit = 20, category, level, search } = req.query;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
const userTier = req.user?.reseller?.tier || 'bronze';
|
||||||
|
|
||||||
|
const where = { status: 'active' };
|
||||||
|
|
||||||
|
// Filter by category and level
|
||||||
|
if (category) where.category = category;
|
||||||
|
if (level) where.level = level;
|
||||||
|
|
||||||
|
// Search functionality
|
||||||
|
if (search) {
|
||||||
|
where[Op.or] = [
|
||||||
|
{ title: { [Op.iLike]: `%${search}%` } },
|
||||||
|
{ description: { [Op.iLike]: `%${search}%` } },
|
||||||
|
{ tags: { [Op.contains]: [search] } }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Access level filtering
|
||||||
|
where[Op.or] = [
|
||||||
|
{ accessLevel: 'public' },
|
||||||
|
{ accessLevel: 'reseller_only' },
|
||||||
|
{
|
||||||
|
accessLevel: 'tier_specific',
|
||||||
|
tierAccess: { [Op.contains]: [userTier] }
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const { count, rows: courses } = await Course.findAndCountAll({
|
||||||
|
where,
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: User,
|
||||||
|
as: 'creator',
|
||||||
|
attributes: ['id', 'firstName', 'lastName']
|
||||||
|
}
|
||||||
|
],
|
||||||
|
attributes: { exclude: ['videoPath', 'materials'] }, // Exclude large fields from list
|
||||||
|
limit: parseInt(limit),
|
||||||
|
offset: parseInt(offset),
|
||||||
|
order: [['createdAt', 'DESC']]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check enrollment status for each course
|
||||||
|
const coursesWithEnrollment = await Promise.all(
|
||||||
|
courses.map(async (course) => {
|
||||||
|
const enrollment = await CourseEnrollment.findOne({
|
||||||
|
where: {
|
||||||
|
courseId: course.id,
|
||||||
|
userId: req.user.id
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...course.toJSON(),
|
||||||
|
enrollment: enrollment ? {
|
||||||
|
id: enrollment.id,
|
||||||
|
status: enrollment.status,
|
||||||
|
progress: enrollment.progress,
|
||||||
|
enrolledAt: enrollment.enrolledAt,
|
||||||
|
completedAt: enrollment.completedAt
|
||||||
|
} : null
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
courses: coursesWithEnrollment,
|
||||||
|
pagination: {
|
||||||
|
total: count,
|
||||||
|
page: parseInt(page),
|
||||||
|
pages: Math.ceil(count / limit),
|
||||||
|
limit: parseInt(limit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get courses error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to fetch courses'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get course details
|
||||||
|
const getCourseDetails = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { courseId } = req.params;
|
||||||
|
const userTier = req.user?.reseller?.tier || 'bronze';
|
||||||
|
|
||||||
|
const course = await Course.findByPk(courseId, {
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: User,
|
||||||
|
as: 'creator',
|
||||||
|
attributes: ['id', 'firstName', 'lastName']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!course) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Course not found'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check access permissions
|
||||||
|
if (!course.isAccessibleBy(userTier)) {
|
||||||
|
return res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Access denied for this course'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user's enrollment
|
||||||
|
const enrollment = await CourseEnrollment.findOne({
|
||||||
|
where: {
|
||||||
|
courseId: course.id,
|
||||||
|
userId: req.user.id
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
course,
|
||||||
|
enrollment: enrollment ? {
|
||||||
|
id: enrollment.id,
|
||||||
|
status: enrollment.status,
|
||||||
|
progress: enrollment.progress,
|
||||||
|
enrolledAt: enrollment.enrolledAt,
|
||||||
|
startedAt: enrollment.startedAt,
|
||||||
|
completedAt: enrollment.completedAt,
|
||||||
|
timeSpent: enrollment.timeSpent,
|
||||||
|
attempts: enrollment.attempts,
|
||||||
|
bestScore: enrollment.bestScore,
|
||||||
|
moduleProgress: enrollment.moduleProgress
|
||||||
|
} : null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get course details error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to fetch course details'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Enroll in course
|
||||||
|
const enrollInCourse = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { courseId } = req.params;
|
||||||
|
const userTier = req.user?.reseller?.tier || 'bronze';
|
||||||
|
|
||||||
|
const course = await Course.findByPk(courseId);
|
||||||
|
if (!course) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Course not found'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check access permissions
|
||||||
|
if (!course.isAccessibleBy(userTier)) {
|
||||||
|
return res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Access denied for this course'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already enrolled
|
||||||
|
const existingEnrollment = await CourseEnrollment.findOne({
|
||||||
|
where: {
|
||||||
|
courseId: course.id,
|
||||||
|
userId: req.user.id
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingEnrollment) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Already enrolled in this course'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create enrollment
|
||||||
|
const enrollment = await CourseEnrollment.create({
|
||||||
|
courseId: course.id,
|
||||||
|
userId: req.user.id,
|
||||||
|
resellerId: req.user.resellerId,
|
||||||
|
status: 'enrolled'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log audit
|
||||||
|
await AuditLog.create({
|
||||||
|
userId: req.user.id,
|
||||||
|
action: 'COURSE_ENROLLED',
|
||||||
|
resource: 'course',
|
||||||
|
resourceId: course.id,
|
||||||
|
newValues: { enrollmentId: enrollment.id },
|
||||||
|
ipAddress: req.ip,
|
||||||
|
userAgent: req.get('User-Agent')
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Successfully enrolled in course',
|
||||||
|
data: { enrollment }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Enroll in course error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to enroll in course'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update course progress
|
||||||
|
const updateCourseProgress = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { courseId } = req.params;
|
||||||
|
const { moduleId, moduleProgress, timeSpent = 0 } = req.body;
|
||||||
|
|
||||||
|
const enrollment = await CourseEnrollment.findOne({
|
||||||
|
where: {
|
||||||
|
courseId,
|
||||||
|
userId: req.user.id
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!enrollment) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Enrollment not found'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update progress
|
||||||
|
await enrollment.updateProgress(moduleId, moduleProgress);
|
||||||
|
|
||||||
|
// Update time spent
|
||||||
|
await enrollment.update({
|
||||||
|
timeSpent: enrollment.timeSpent + timeSpent,
|
||||||
|
status: enrollment.progress >= 100 ? 'completed' : 'in_progress',
|
||||||
|
completedAt: enrollment.progress >= 100 ? new Date() : null
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Progress updated successfully',
|
||||||
|
data: { enrollment }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Update course progress error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to update course progress'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get user certificates
|
||||||
|
const getUserCertificates = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { page = 1, limit = 20 } = req.query;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
const { count, rows: certificates } = await Certificate.findAndCountAll({
|
||||||
|
where: {
|
||||||
|
userId: req.user.id,
|
||||||
|
resellerId: req.user.resellerId
|
||||||
|
},
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: Course,
|
||||||
|
as: 'course',
|
||||||
|
attributes: ['id', 'title', 'category', 'level']
|
||||||
|
}
|
||||||
|
],
|
||||||
|
limit: parseInt(limit),
|
||||||
|
offset: parseInt(offset),
|
||||||
|
order: [['issuedAt', 'DESC']]
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
certificates,
|
||||||
|
pagination: {
|
||||||
|
total: count,
|
||||||
|
page: parseInt(page),
|
||||||
|
pages: Math.ceil(count / limit),
|
||||||
|
limit: parseInt(limit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get user certificates error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to fetch certificates'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Issue certificate
|
||||||
|
const issueCertificate = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { enrollmentId } = req.params;
|
||||||
|
const { score, grade } = req.body;
|
||||||
|
|
||||||
|
const enrollment = await CourseEnrollment.findByPk(enrollmentId, {
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: Course,
|
||||||
|
as: 'course'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!enrollment) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Enrollment not found'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!enrollment.isCompleted()) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Course must be completed to issue certificate'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if certificate already exists
|
||||||
|
const existingCertificate = await Certificate.findOne({
|
||||||
|
where: { enrollmentId: enrollment.id }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingCertificate) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Certificate already issued for this enrollment'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate certificate number and verification code
|
||||||
|
const certificateNumber = `CERT-${Date.now()}-${crypto.randomBytes(4).toString('hex').toUpperCase()}`;
|
||||||
|
const verificationCode = crypto.randomBytes(16).toString('hex');
|
||||||
|
|
||||||
|
// Calculate expiry date if course has validity period
|
||||||
|
let expiresAt = null;
|
||||||
|
if (enrollment.course.validityPeriod) {
|
||||||
|
expiresAt = new Date();
|
||||||
|
expiresAt.setDate(expiresAt.getDate() + enrollment.course.validityPeriod);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create certificate
|
||||||
|
const certificate = await Certificate.create({
|
||||||
|
certificateNumber,
|
||||||
|
courseId: enrollment.courseId,
|
||||||
|
userId: enrollment.userId,
|
||||||
|
resellerId: enrollment.resellerId,
|
||||||
|
enrollmentId: enrollment.id,
|
||||||
|
score,
|
||||||
|
grade,
|
||||||
|
expiresAt,
|
||||||
|
verificationCode,
|
||||||
|
issuedBy: req.user.id
|
||||||
|
});
|
||||||
|
|
||||||
|
// TODO: Generate PDF certificate file
|
||||||
|
// This would involve creating a PDF using the certificate template
|
||||||
|
|
||||||
|
// Log audit
|
||||||
|
await AuditLog.create({
|
||||||
|
userId: req.user.id,
|
||||||
|
action: 'CERTIFICATE_ISSUED',
|
||||||
|
resource: 'certificate',
|
||||||
|
resourceId: certificate.id,
|
||||||
|
newValues: { certificateNumber, courseId: enrollment.courseId },
|
||||||
|
ipAddress: req.ip,
|
||||||
|
userAgent: req.get('User-Agent')
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Certificate issued successfully',
|
||||||
|
data: { certificate }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Issue certificate error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to issue certificate'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getCourses,
|
||||||
|
getCourseDetails,
|
||||||
|
enrollInCourse,
|
||||||
|
updateCourseProgress,
|
||||||
|
getUserCertificates,
|
||||||
|
issueCertificate
|
||||||
|
};
|
||||||
191
src/middleware/auth.js
Normal file
191
src/middleware/auth.js
Normal file
@ -0,0 +1,191 @@
|
|||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
const { User, UserSession } = require('../models');
|
||||||
|
const redisClient = require('../config/redis');
|
||||||
|
|
||||||
|
// Authenticate JWT token
|
||||||
|
const authenticateToken = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const authHeader = req.headers['authorization'];
|
||||||
|
const token = authHeader && authHeader.split(' ')[1];
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Access token required'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if token is blacklisted
|
||||||
|
const isBlacklisted = await redisClient.get(`blacklist:${token}`);
|
||||||
|
if (isBlacklisted) {
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Token has been invalidated'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||||
|
|
||||||
|
// Get user from database
|
||||||
|
const user = await User.findByPk(decoded.userId, {
|
||||||
|
include: ['reseller']
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
message: 'User not found'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.status !== 'active') {
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Account is not active'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
req.user = user;
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
if (error.name === 'TokenExpiredError') {
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Token expired'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.name === 'JsonWebTokenError') {
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Invalid token'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('Auth middleware error:', error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Authentication error'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Authorize based on roles
|
||||||
|
const authorize = (...roles) => {
|
||||||
|
return (req, res, next) => {
|
||||||
|
if (!req.user) {
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Authentication required'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!roles.includes(req.user.role)) {
|
||||||
|
return res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Insufficient permissions'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Verify MFA token
|
||||||
|
const verifyMFA = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
if (!req.user.mfaEnabled) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
const mfaToken = req.headers['x-mfa-token'];
|
||||||
|
if (!mfaToken) {
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
message: 'MFA token required'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValid = req.user.verifyMfaToken(mfaToken);
|
||||||
|
if (!isValid) {
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Invalid MFA token'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('MFA verification error:', error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'MFA verification error'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if user account is locked
|
||||||
|
const checkAccountLock = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
if (req.user && req.user.isLocked()) {
|
||||||
|
return res.status(423).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Account is temporarily locked due to multiple failed login attempts'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Account lock check error:', error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Account verification error'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Refresh token validation
|
||||||
|
const validateRefreshToken = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { refreshToken } = req.body;
|
||||||
|
|
||||||
|
if (!refreshToken) {
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Refresh token required'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await UserSession.findOne({
|
||||||
|
where: {
|
||||||
|
refreshToken,
|
||||||
|
isActive: true
|
||||||
|
},
|
||||||
|
include: ['user']
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session || session.isExpired()) {
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Invalid or expired refresh token'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
req.session = session;
|
||||||
|
req.user = session.user;
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Refresh token validation error:', error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Token validation error'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
authenticateToken,
|
||||||
|
authorize,
|
||||||
|
verifyMFA,
|
||||||
|
checkAccountLock,
|
||||||
|
validateRefreshToken
|
||||||
|
};
|
||||||
77
src/middleware/errorHandler.js
Normal file
77
src/middleware/errorHandler.js
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
const { ValidationError, UniqueConstraintError, ForeignKeyConstraintError } = require('sequelize');
|
||||||
|
|
||||||
|
const errorHandler = (err, req, res, next) => {
|
||||||
|
let error = { ...err };
|
||||||
|
error.message = err.message;
|
||||||
|
|
||||||
|
// Log error
|
||||||
|
console.error('Error:', err);
|
||||||
|
|
||||||
|
// Sequelize validation error
|
||||||
|
if (err instanceof ValidationError) {
|
||||||
|
const message = err.errors.map(e => e.message).join(', ');
|
||||||
|
error = {
|
||||||
|
statusCode: 400,
|
||||||
|
message: `Validation Error: ${message}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sequelize unique constraint error
|
||||||
|
if (err instanceof UniqueConstraintError) {
|
||||||
|
const field = err.errors[0].path;
|
||||||
|
error = {
|
||||||
|
statusCode: 409,
|
||||||
|
message: `${field} already exists`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sequelize foreign key constraint error
|
||||||
|
if (err instanceof ForeignKeyConstraintError) {
|
||||||
|
error = {
|
||||||
|
statusCode: 400,
|
||||||
|
message: 'Invalid reference to related resource'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// JWT errors
|
||||||
|
if (err.name === 'JsonWebTokenError') {
|
||||||
|
error = {
|
||||||
|
statusCode: 401,
|
||||||
|
message: 'Invalid token'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (err.name === 'TokenExpiredError') {
|
||||||
|
error = {
|
||||||
|
statusCode: 401,
|
||||||
|
message: 'Token expired'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multer errors (file upload)
|
||||||
|
if (err.code === 'LIMIT_FILE_SIZE') {
|
||||||
|
error = {
|
||||||
|
statusCode: 413,
|
||||||
|
message: 'File too large'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (err.code === 'LIMIT_UNEXPECTED_FILE') {
|
||||||
|
error = {
|
||||||
|
statusCode: 400,
|
||||||
|
message: 'Unexpected file field'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default error
|
||||||
|
const statusCode = error.statusCode || err.statusCode || 500;
|
||||||
|
const message = error.message || 'Internal Server Error';
|
||||||
|
|
||||||
|
res.status(statusCode).json({
|
||||||
|
success: false,
|
||||||
|
message,
|
||||||
|
...(process.env.NODE_ENV === 'development' && { stack: err.stack })
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = errorHandler;
|
||||||
151
src/middleware/upload.js
Normal file
151
src/middleware/upload.js
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
const multer = require('multer');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const { v4: uuidv4 } = require('uuid');
|
||||||
|
|
||||||
|
// Ensure upload directories exist
|
||||||
|
const ensureUploadDirs = () => {
|
||||||
|
const dirs = [
|
||||||
|
'uploads/documents',
|
||||||
|
'uploads/avatars',
|
||||||
|
'uploads/kyc',
|
||||||
|
'uploads/temp'
|
||||||
|
];
|
||||||
|
|
||||||
|
dirs.forEach(dir => {
|
||||||
|
if (!fs.existsSync(dir)) {
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
ensureUploadDirs();
|
||||||
|
|
||||||
|
// File filter function
|
||||||
|
const fileFilter = (allowedTypes) => {
|
||||||
|
return (req, file, cb) => {
|
||||||
|
const fileExtension = path.extname(file.originalname).toLowerCase();
|
||||||
|
const allowedExtensions = allowedTypes.map(type => `.${type}`);
|
||||||
|
|
||||||
|
if (allowedExtensions.includes(fileExtension)) {
|
||||||
|
cb(null, true);
|
||||||
|
} else {
|
||||||
|
cb(new Error(`File type not allowed. Allowed types: ${allowedTypes.join(', ')}`), false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Storage configuration
|
||||||
|
const createStorage = (uploadPath) => {
|
||||||
|
return multer.diskStorage({
|
||||||
|
destination: (req, file, cb) => {
|
||||||
|
cb(null, uploadPath);
|
||||||
|
},
|
||||||
|
filename: (req, file, cb) => {
|
||||||
|
const uniqueSuffix = uuidv4();
|
||||||
|
const fileExtension = path.extname(file.originalname);
|
||||||
|
cb(null, `${uniqueSuffix}${fileExtension}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Document upload (KYC, contracts, etc.)
|
||||||
|
const documentUpload = multer({
|
||||||
|
storage: createStorage('uploads/documents'),
|
||||||
|
limits: {
|
||||||
|
fileSize: parseInt(process.env.MAX_FILE_SIZE) || 5 * 1024 * 1024, // 5MB
|
||||||
|
files: 10
|
||||||
|
},
|
||||||
|
fileFilter: fileFilter(['pdf', 'doc', 'docx', 'jpg', 'jpeg', 'png'])
|
||||||
|
});
|
||||||
|
|
||||||
|
// Avatar upload
|
||||||
|
const avatarUpload = multer({
|
||||||
|
storage: createStorage('uploads/avatars'),
|
||||||
|
limits: {
|
||||||
|
fileSize: 2 * 1024 * 1024, // 2MB
|
||||||
|
files: 1
|
||||||
|
},
|
||||||
|
fileFilter: fileFilter(['jpg', 'jpeg', 'png', 'gif'])
|
||||||
|
});
|
||||||
|
|
||||||
|
// KYC document upload
|
||||||
|
const kycUpload = multer({
|
||||||
|
storage: createStorage('uploads/kyc'),
|
||||||
|
limits: {
|
||||||
|
fileSize: parseInt(process.env.MAX_FILE_SIZE) || 5 * 1024 * 1024, // 5MB
|
||||||
|
files: 5
|
||||||
|
},
|
||||||
|
fileFilter: fileFilter(['pdf', 'jpg', 'jpeg', 'png'])
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generic file upload
|
||||||
|
const genericUpload = multer({
|
||||||
|
storage: createStorage('uploads/temp'),
|
||||||
|
limits: {
|
||||||
|
fileSize: parseInt(process.env.MAX_FILE_SIZE) || 5 * 1024 * 1024, // 5MB
|
||||||
|
files: 5
|
||||||
|
},
|
||||||
|
fileFilter: fileFilter(['pdf', 'doc', 'docx', 'jpg', 'jpeg', 'png', 'txt', 'csv', 'xlsx'])
|
||||||
|
});
|
||||||
|
|
||||||
|
// File validation middleware
|
||||||
|
const validateFile = (req, res, next) => {
|
||||||
|
if (!req.file && !req.files) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'No file uploaded'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
|
// File cleanup utility
|
||||||
|
const cleanupFile = (filePath) => {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(filePath)) {
|
||||||
|
fs.unlinkSync(filePath);
|
||||||
|
console.log(`File deleted: ${filePath}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error deleting file ${filePath}:`, error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// File info extractor
|
||||||
|
const extractFileInfo = (file) => {
|
||||||
|
return {
|
||||||
|
originalName: file.originalname,
|
||||||
|
filename: file.filename,
|
||||||
|
path: file.path,
|
||||||
|
size: file.size,
|
||||||
|
mimetype: file.mimetype,
|
||||||
|
uploadedAt: new Date()
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Multiple file info extractor
|
||||||
|
const extractMultipleFileInfo = (files) => {
|
||||||
|
if (Array.isArray(files)) {
|
||||||
|
return files.map(extractFileInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle multer's field-based file object
|
||||||
|
const result = {};
|
||||||
|
Object.keys(files).forEach(fieldName => {
|
||||||
|
result[fieldName] = files[fieldName].map(extractFileInfo);
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
documentUpload,
|
||||||
|
avatarUpload,
|
||||||
|
kycUpload,
|
||||||
|
genericUpload,
|
||||||
|
validateFile,
|
||||||
|
cleanupFile,
|
||||||
|
extractFileInfo,
|
||||||
|
extractMultipleFileInfo,
|
||||||
|
ensureUploadDirs
|
||||||
|
};
|
||||||
119
src/migrations/20250130000001-create-users.js
Normal file
119
src/migrations/20250130000001-create-users.js
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
async up(queryInterface, Sequelize) {
|
||||||
|
await queryInterface.createTable('users', {
|
||||||
|
id: {
|
||||||
|
type: Sequelize.UUID,
|
||||||
|
defaultValue: Sequelize.UUIDV4,
|
||||||
|
primaryKey: true
|
||||||
|
},
|
||||||
|
email: {
|
||||||
|
type: Sequelize.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
unique: true,
|
||||||
|
validate: {
|
||||||
|
isEmail: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
password: {
|
||||||
|
type: Sequelize.STRING,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
firstName: {
|
||||||
|
type: Sequelize.STRING,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
lastName: {
|
||||||
|
type: Sequelize.STRING,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
phone: {
|
||||||
|
type: Sequelize.STRING,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
role: {
|
||||||
|
type: Sequelize.ENUM('reseller', 'reseller_admin', 'customer', 'support', 'admin'),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'reseller'
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
type: Sequelize.ENUM('pending', 'active', 'suspended', 'inactive'),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'pending'
|
||||||
|
},
|
||||||
|
emailVerified: {
|
||||||
|
type: Sequelize.BOOLEAN,
|
||||||
|
defaultValue: false
|
||||||
|
},
|
||||||
|
emailVerificationToken: {
|
||||||
|
type: Sequelize.STRING,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
passwordResetToken: {
|
||||||
|
type: Sequelize.STRING,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
passwordResetExpires: {
|
||||||
|
type: Sequelize.DATE,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
mfaEnabled: {
|
||||||
|
type: Sequelize.BOOLEAN,
|
||||||
|
defaultValue: false
|
||||||
|
},
|
||||||
|
mfaSecret: {
|
||||||
|
type: Sequelize.STRING,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
mfaBackupCodes: {
|
||||||
|
type: Sequelize.JSON,
|
||||||
|
defaultValue: []
|
||||||
|
},
|
||||||
|
refreshToken: {
|
||||||
|
type: Sequelize.TEXT,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
refreshTokenExpires: {
|
||||||
|
type: Sequelize.DATE,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
loginAttempts: {
|
||||||
|
type: Sequelize.INTEGER,
|
||||||
|
defaultValue: 0
|
||||||
|
},
|
||||||
|
lockUntil: {
|
||||||
|
type: Sequelize.DATE,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
lastLogin: {
|
||||||
|
type: Sequelize.DATE,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
avatar: {
|
||||||
|
type: Sequelize.STRING,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
preferences: {
|
||||||
|
type: Sequelize.JSON,
|
||||||
|
defaultValue: {}
|
||||||
|
},
|
||||||
|
createdAt: {
|
||||||
|
allowNull: false,
|
||||||
|
type: Sequelize.DATE
|
||||||
|
},
|
||||||
|
updatedAt: {
|
||||||
|
allowNull: false,
|
||||||
|
type: Sequelize.DATE
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await queryInterface.addIndex('users', ['email']);
|
||||||
|
await queryInterface.addIndex('users', ['role']);
|
||||||
|
await queryInterface.addIndex('users', ['status']);
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(queryInterface, Sequelize) {
|
||||||
|
await queryInterface.dropTable('users');
|
||||||
|
}
|
||||||
|
};
|
||||||
140
src/migrations/20250130000002-create-resellers.js
Normal file
140
src/migrations/20250130000002-create-resellers.js
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
async up(queryInterface, Sequelize) {
|
||||||
|
await queryInterface.createTable('resellers', {
|
||||||
|
id: {
|
||||||
|
type: Sequelize.UUID,
|
||||||
|
defaultValue: Sequelize.UUIDV4,
|
||||||
|
primaryKey: true
|
||||||
|
},
|
||||||
|
userId: {
|
||||||
|
type: Sequelize.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
references: {
|
||||||
|
model: 'users',
|
||||||
|
key: 'id'
|
||||||
|
},
|
||||||
|
onUpdate: 'CASCADE',
|
||||||
|
onDelete: 'CASCADE'
|
||||||
|
},
|
||||||
|
companyName: {
|
||||||
|
type: Sequelize.STRING,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
businessType: {
|
||||||
|
type: Sequelize.ENUM('individual', 'partnership', 'private_limited', 'public_limited', 'llp', 'other'),
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
registrationNumber: {
|
||||||
|
type: Sequelize.STRING,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
taxId: {
|
||||||
|
type: Sequelize.STRING,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
website: {
|
||||||
|
type: Sequelize.STRING,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: Sequelize.TEXT,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
address: {
|
||||||
|
type: Sequelize.JSON,
|
||||||
|
defaultValue: {}
|
||||||
|
},
|
||||||
|
contactPerson: {
|
||||||
|
type: Sequelize.STRING,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
contactEmail: {
|
||||||
|
type: Sequelize.STRING,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
contactPhone: {
|
||||||
|
type: Sequelize.STRING,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
tier: {
|
||||||
|
type: Sequelize.ENUM('bronze', 'silver', 'gold', 'platinum', 'diamond'),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'bronze'
|
||||||
|
},
|
||||||
|
commissionRate: {
|
||||||
|
type: Sequelize.DECIMAL(5, 2),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 10.00
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
type: Sequelize.ENUM('pending_approval', 'active', 'suspended', 'inactive', 'rejected'),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'pending_approval'
|
||||||
|
},
|
||||||
|
kycStatus: {
|
||||||
|
type: Sequelize.ENUM('pending', 'submitted', 'under_review', 'approved', 'rejected'),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'pending'
|
||||||
|
},
|
||||||
|
kycDocuments: {
|
||||||
|
type: Sequelize.JSON,
|
||||||
|
defaultValue: []
|
||||||
|
},
|
||||||
|
kycNotes: {
|
||||||
|
type: Sequelize.TEXT,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
approvedBy: {
|
||||||
|
type: Sequelize.UUID,
|
||||||
|
allowNull: true,
|
||||||
|
references: {
|
||||||
|
model: 'users',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
approvedAt: {
|
||||||
|
type: Sequelize.DATE,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
rejectedAt: {
|
||||||
|
type: Sequelize.DATE,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
rejectionReason: {
|
||||||
|
type: Sequelize.TEXT,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
billingAddress: {
|
||||||
|
type: Sequelize.JSON,
|
||||||
|
defaultValue: {}
|
||||||
|
},
|
||||||
|
bankDetails: {
|
||||||
|
type: Sequelize.JSON,
|
||||||
|
defaultValue: {}
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
type: Sequelize.JSON,
|
||||||
|
defaultValue: {}
|
||||||
|
},
|
||||||
|
createdAt: {
|
||||||
|
allowNull: false,
|
||||||
|
type: Sequelize.DATE
|
||||||
|
},
|
||||||
|
updatedAt: {
|
||||||
|
allowNull: false,
|
||||||
|
type: Sequelize.DATE
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await queryInterface.addIndex('resellers', ['user_id']);
|
||||||
|
await queryInterface.addIndex('resellers', ['tier']);
|
||||||
|
await queryInterface.addIndex('resellers', ['status']);
|
||||||
|
await queryInterface.addIndex('resellers', ['kyc_status']);
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(queryInterface, Sequelize) {
|
||||||
|
await queryInterface.dropTable('resellers');
|
||||||
|
}
|
||||||
|
};
|
||||||
131
src/migrations/20250130000003-create-products.js
Normal file
131
src/migrations/20250130000003-create-products.js
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
async up(queryInterface, Sequelize) {
|
||||||
|
await queryInterface.createTable('products', {
|
||||||
|
id: {
|
||||||
|
type: Sequelize.UUID,
|
||||||
|
defaultValue: Sequelize.UUIDV4,
|
||||||
|
primaryKey: true
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
type: Sequelize.STRING,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: Sequelize.TEXT,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
category: {
|
||||||
|
type: Sequelize.ENUM('compute', 'storage', 'networking', 'database', 'security', 'analytics', 'ai_ml', 'other'),
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
subcategory: {
|
||||||
|
type: Sequelize.STRING,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
sku: {
|
||||||
|
type: Sequelize.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
unique: true
|
||||||
|
},
|
||||||
|
basePrice: {
|
||||||
|
type: Sequelize.DECIMAL(10, 2),
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
currency: {
|
||||||
|
type: Sequelize.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'INR'
|
||||||
|
},
|
||||||
|
billingType: {
|
||||||
|
type: Sequelize.ENUM('one_time', 'recurring', 'usage_based', 'tiered'),
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
billingCycle: {
|
||||||
|
type: Sequelize.ENUM('hourly', 'daily', 'weekly', 'monthly', 'quarterly', 'yearly'),
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
unit: {
|
||||||
|
type: Sequelize.STRING,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
specifications: {
|
||||||
|
type: Sequelize.JSON,
|
||||||
|
defaultValue: {}
|
||||||
|
},
|
||||||
|
features: {
|
||||||
|
type: Sequelize.JSON,
|
||||||
|
defaultValue: []
|
||||||
|
},
|
||||||
|
tierPricing: {
|
||||||
|
type: Sequelize.JSON,
|
||||||
|
defaultValue: {
|
||||||
|
bronze: { margin: 20 },
|
||||||
|
silver: { margin: 25 },
|
||||||
|
gold: { margin: 30 },
|
||||||
|
platinum: { margin: 35 },
|
||||||
|
diamond: { margin: 40 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
type: Sequelize.ENUM('active', 'inactive', 'deprecated', 'coming_soon'),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'active'
|
||||||
|
},
|
||||||
|
availability: {
|
||||||
|
type: Sequelize.JSON,
|
||||||
|
defaultValue: {
|
||||||
|
regions: [],
|
||||||
|
zones: []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
minimumCommitment: {
|
||||||
|
type: Sequelize.JSON,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
tags: {
|
||||||
|
type: Sequelize.JSON,
|
||||||
|
defaultValue: []
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
type: Sequelize.JSON,
|
||||||
|
defaultValue: {}
|
||||||
|
},
|
||||||
|
createdBy: {
|
||||||
|
type: Sequelize.UUID,
|
||||||
|
allowNull: true,
|
||||||
|
references: {
|
||||||
|
model: 'users',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
updatedBy: {
|
||||||
|
type: Sequelize.UUID,
|
||||||
|
allowNull: true,
|
||||||
|
references: {
|
||||||
|
model: 'users',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
createdAt: {
|
||||||
|
allowNull: false,
|
||||||
|
type: Sequelize.DATE
|
||||||
|
},
|
||||||
|
updatedAt: {
|
||||||
|
allowNull: false,
|
||||||
|
type: Sequelize.DATE
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await queryInterface.addIndex('products', ['sku'], { unique: true });
|
||||||
|
await queryInterface.addIndex('products', ['category']);
|
||||||
|
await queryInterface.addIndex('products', ['status']);
|
||||||
|
await queryInterface.addIndex('products', ['billing_type']);
|
||||||
|
await queryInterface.addIndex('products', ['name']);
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(queryInterface, Sequelize) {
|
||||||
|
await queryInterface.dropTable('products');
|
||||||
|
}
|
||||||
|
};
|
||||||
103
src/migrations/20250130000004-create-customers.js
Normal file
103
src/migrations/20250130000004-create-customers.js
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
async up(queryInterface, Sequelize) {
|
||||||
|
await queryInterface.createTable('customers', {
|
||||||
|
id: {
|
||||||
|
type: Sequelize.UUID,
|
||||||
|
defaultValue: Sequelize.UUIDV4,
|
||||||
|
primaryKey: true
|
||||||
|
},
|
||||||
|
resellerId: {
|
||||||
|
type: Sequelize.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
references: {
|
||||||
|
model: 'resellers',
|
||||||
|
key: 'id'
|
||||||
|
},
|
||||||
|
onUpdate: 'CASCADE',
|
||||||
|
onDelete: 'CASCADE'
|
||||||
|
},
|
||||||
|
companyName: {
|
||||||
|
type: Sequelize.STRING,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
contactPerson: {
|
||||||
|
type: Sequelize.STRING,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
email: {
|
||||||
|
type: Sequelize.STRING,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
phone: {
|
||||||
|
type: Sequelize.STRING,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
address: {
|
||||||
|
type: Sequelize.JSON,
|
||||||
|
defaultValue: {}
|
||||||
|
},
|
||||||
|
billingAddress: {
|
||||||
|
type: Sequelize.JSON,
|
||||||
|
defaultValue: {}
|
||||||
|
},
|
||||||
|
taxId: {
|
||||||
|
type: Sequelize.STRING,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
businessType: {
|
||||||
|
type: Sequelize.STRING,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
industry: {
|
||||||
|
type: Sequelize.STRING,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
website: {
|
||||||
|
type: Sequelize.STRING,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
type: Sequelize.ENUM('active', 'inactive', 'suspended', 'pending'),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'active'
|
||||||
|
},
|
||||||
|
paymentTerms: {
|
||||||
|
type: Sequelize.ENUM('prepaid', 'postpaid', 'credit'),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'prepaid'
|
||||||
|
},
|
||||||
|
creditLimit: {
|
||||||
|
type: Sequelize.DECIMAL(12, 2),
|
||||||
|
allowNull: true,
|
||||||
|
defaultValue: 0
|
||||||
|
},
|
||||||
|
notes: {
|
||||||
|
type: Sequelize.TEXT,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
type: Sequelize.JSON,
|
||||||
|
defaultValue: {}
|
||||||
|
},
|
||||||
|
createdAt: {
|
||||||
|
allowNull: false,
|
||||||
|
type: Sequelize.DATE
|
||||||
|
},
|
||||||
|
updatedAt: {
|
||||||
|
allowNull: false,
|
||||||
|
type: Sequelize.DATE
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await queryInterface.addIndex('customers', ['reseller_id']);
|
||||||
|
await queryInterface.addIndex('customers', ['email']);
|
||||||
|
await queryInterface.addIndex('customers', ['status']);
|
||||||
|
await queryInterface.addIndex('customers', ['payment_terms']);
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(queryInterface, Sequelize) {
|
||||||
|
await queryInterface.dropTable('customers');
|
||||||
|
}
|
||||||
|
};
|
||||||
85
src/migrations/20250130000005-create-wallets.js
Normal file
85
src/migrations/20250130000005-create-wallets.js
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
async up(queryInterface, Sequelize) {
|
||||||
|
await queryInterface.createTable('wallets', {
|
||||||
|
id: {
|
||||||
|
type: Sequelize.UUID,
|
||||||
|
defaultValue: Sequelize.UUIDV4,
|
||||||
|
primaryKey: true
|
||||||
|
},
|
||||||
|
customerId: {
|
||||||
|
type: Sequelize.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
references: {
|
||||||
|
model: 'customers',
|
||||||
|
key: 'id'
|
||||||
|
},
|
||||||
|
onUpdate: 'CASCADE',
|
||||||
|
onDelete: 'CASCADE'
|
||||||
|
},
|
||||||
|
balance: {
|
||||||
|
type: Sequelize.DECIMAL(12, 2),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 0.00
|
||||||
|
},
|
||||||
|
currency: {
|
||||||
|
type: Sequelize.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'INR'
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
type: Sequelize.ENUM('prepaid', 'postpaid', 'credit'),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'prepaid'
|
||||||
|
},
|
||||||
|
creditLimit: {
|
||||||
|
type: Sequelize.DECIMAL(12, 2),
|
||||||
|
allowNull: true,
|
||||||
|
defaultValue: 0.00
|
||||||
|
},
|
||||||
|
lowBalanceThreshold: {
|
||||||
|
type: Sequelize.DECIMAL(12, 2),
|
||||||
|
allowNull: true,
|
||||||
|
defaultValue: 100.00
|
||||||
|
},
|
||||||
|
autoRecharge: {
|
||||||
|
type: Sequelize.BOOLEAN,
|
||||||
|
defaultValue: false
|
||||||
|
},
|
||||||
|
autoRechargeAmount: {
|
||||||
|
type: Sequelize.DECIMAL(12, 2),
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
type: Sequelize.ENUM('active', 'suspended', 'frozen'),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'active'
|
||||||
|
},
|
||||||
|
lastTransaction: {
|
||||||
|
type: Sequelize.DATE,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
type: Sequelize.JSON,
|
||||||
|
defaultValue: {}
|
||||||
|
},
|
||||||
|
createdAt: {
|
||||||
|
allowNull: false,
|
||||||
|
type: Sequelize.DATE
|
||||||
|
},
|
||||||
|
updatedAt: {
|
||||||
|
allowNull: false,
|
||||||
|
type: Sequelize.DATE
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await queryInterface.addIndex('wallets', ['customer_id']);
|
||||||
|
await queryInterface.addIndex('wallets', ['type']);
|
||||||
|
await queryInterface.addIndex('wallets', ['status']);
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(queryInterface, Sequelize) {
|
||||||
|
await queryInterface.dropTable('wallets');
|
||||||
|
}
|
||||||
|
};
|
||||||
124
src/migrations/20250130000006-create-invoices.js
Normal file
124
src/migrations/20250130000006-create-invoices.js
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
async up(queryInterface, Sequelize) {
|
||||||
|
await queryInterface.createTable('invoices', {
|
||||||
|
id: {
|
||||||
|
type: Sequelize.UUID,
|
||||||
|
defaultValue: Sequelize.UUIDV4,
|
||||||
|
primaryKey: true
|
||||||
|
},
|
||||||
|
invoiceNumber: {
|
||||||
|
type: Sequelize.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
unique: true
|
||||||
|
},
|
||||||
|
resellerId: {
|
||||||
|
type: Sequelize.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
references: {
|
||||||
|
model: 'resellers',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
customerId: {
|
||||||
|
type: Sequelize.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
references: {
|
||||||
|
model: 'customers',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
orderId: {
|
||||||
|
type: Sequelize.UUID,
|
||||||
|
allowNull: true,
|
||||||
|
references: {
|
||||||
|
model: 'orders',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
issueDate: {
|
||||||
|
type: Sequelize.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: Sequelize.NOW
|
||||||
|
},
|
||||||
|
dueDate: {
|
||||||
|
type: Sequelize.DATE,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
subtotal: {
|
||||||
|
type: Sequelize.DECIMAL(12, 2),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 0.00
|
||||||
|
},
|
||||||
|
taxAmount: {
|
||||||
|
type: Sequelize.DECIMAL(12, 2),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 0.00
|
||||||
|
},
|
||||||
|
discountAmount: {
|
||||||
|
type: Sequelize.DECIMAL(12, 2),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 0.00
|
||||||
|
},
|
||||||
|
totalAmount: {
|
||||||
|
type: Sequelize.DECIMAL(12, 2),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 0.00
|
||||||
|
},
|
||||||
|
currency: {
|
||||||
|
type: Sequelize.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'INR'
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
type: Sequelize.ENUM('draft', 'sent', 'paid', 'overdue', 'cancelled', 'refunded'),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'draft'
|
||||||
|
},
|
||||||
|
paidAt: {
|
||||||
|
type: Sequelize.DATE,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
paymentMethod: {
|
||||||
|
type: Sequelize.STRING,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
paymentReference: {
|
||||||
|
type: Sequelize.STRING,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
notes: {
|
||||||
|
type: Sequelize.TEXT,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
terms: {
|
||||||
|
type: Sequelize.TEXT,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
type: Sequelize.JSON,
|
||||||
|
defaultValue: {}
|
||||||
|
},
|
||||||
|
createdAt: {
|
||||||
|
allowNull: false,
|
||||||
|
type: Sequelize.DATE
|
||||||
|
},
|
||||||
|
updatedAt: {
|
||||||
|
allowNull: false,
|
||||||
|
type: Sequelize.DATE
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await queryInterface.addIndex('invoices', ['invoice_number'], { unique: true });
|
||||||
|
await queryInterface.addIndex('invoices', ['reseller_id']);
|
||||||
|
await queryInterface.addIndex('invoices', ['customer_id']);
|
||||||
|
await queryInterface.addIndex('invoices', ['status']);
|
||||||
|
await queryInterface.addIndex('invoices', ['issue_date']);
|
||||||
|
await queryInterface.addIndex('invoices', ['due_date']);
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(queryInterface, Sequelize) {
|
||||||
|
await queryInterface.dropTable('invoices');
|
||||||
|
}
|
||||||
|
};
|
||||||
95
src/migrations/20250130000007-create-orders.js
Normal file
95
src/migrations/20250130000007-create-orders.js
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
async up(queryInterface, Sequelize) {
|
||||||
|
await queryInterface.createTable('orders', {
|
||||||
|
id: {
|
||||||
|
type: Sequelize.UUID,
|
||||||
|
defaultValue: Sequelize.UUIDV4,
|
||||||
|
primaryKey: true
|
||||||
|
},
|
||||||
|
orderNumber: {
|
||||||
|
type: Sequelize.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
unique: true
|
||||||
|
},
|
||||||
|
resellerId: {
|
||||||
|
type: Sequelize.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
references: {
|
||||||
|
model: 'resellers',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
customerId: {
|
||||||
|
type: Sequelize.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
references: {
|
||||||
|
model: 'customers',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
type: Sequelize.ENUM('draft', 'pending', 'confirmed', 'processing', 'completed', 'cancelled', 'refunded'),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'draft'
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
type: Sequelize.ENUM('new_service', 'upgrade', 'downgrade', 'renewal', 'addon'),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'new_service'
|
||||||
|
},
|
||||||
|
subtotal: {
|
||||||
|
type: Sequelize.DECIMAL(12, 2),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 0.00
|
||||||
|
},
|
||||||
|
taxAmount: {
|
||||||
|
type: Sequelize.DECIMAL(12, 2),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 0.00
|
||||||
|
},
|
||||||
|
discountAmount: {
|
||||||
|
type: Sequelize.DECIMAL(12, 2),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 0.00
|
||||||
|
},
|
||||||
|
totalAmount: {
|
||||||
|
type: Sequelize.DECIMAL(12, 2),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 0.00
|
||||||
|
},
|
||||||
|
currency: {
|
||||||
|
type: Sequelize.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'INR'
|
||||||
|
},
|
||||||
|
notes: {
|
||||||
|
type: Sequelize.TEXT,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
type: Sequelize.JSON,
|
||||||
|
defaultValue: {}
|
||||||
|
},
|
||||||
|
createdAt: {
|
||||||
|
allowNull: false,
|
||||||
|
type: Sequelize.DATE
|
||||||
|
},
|
||||||
|
updatedAt: {
|
||||||
|
allowNull: false,
|
||||||
|
type: Sequelize.DATE
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await queryInterface.addIndex('orders', ['order_number'], { unique: true });
|
||||||
|
await queryInterface.addIndex('orders', ['reseller_id']);
|
||||||
|
await queryInterface.addIndex('orders', ['customer_id']);
|
||||||
|
await queryInterface.addIndex('orders', ['status']);
|
||||||
|
await queryInterface.addIndex('orders', ['type']);
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(queryInterface, Sequelize) {
|
||||||
|
await queryInterface.dropTable('orders');
|
||||||
|
}
|
||||||
|
};
|
||||||
90
src/models/AssetDownload.js
Normal file
90
src/models/AssetDownload.js
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
module.exports = (sequelize, DataTypes) => {
|
||||||
|
const AssetDownload = sequelize.define('AssetDownload', {
|
||||||
|
id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true
|
||||||
|
},
|
||||||
|
assetId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
references: {
|
||||||
|
model: 'marketing_assets',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
userId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
references: {
|
||||||
|
model: 'users',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
resellerId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: true,
|
||||||
|
references: {
|
||||||
|
model: 'resellers',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
downloadedAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: DataTypes.NOW
|
||||||
|
},
|
||||||
|
ipAddress: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
userAgent: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
purpose: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: true // e.g., 'client_presentation', 'proposal', 'marketing_campaign'
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
defaultValue: {}
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
tableName: 'asset_downloads',
|
||||||
|
indexes: [
|
||||||
|
{
|
||||||
|
fields: ['asset_id']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['user_id']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['reseller_id']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['downloaded_at']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Class methods
|
||||||
|
AssetDownload.associate = function(models) {
|
||||||
|
AssetDownload.belongsTo(models.MarketingAsset, {
|
||||||
|
foreignKey: 'assetId',
|
||||||
|
as: 'asset'
|
||||||
|
});
|
||||||
|
|
||||||
|
AssetDownload.belongsTo(models.User, {
|
||||||
|
foreignKey: 'userId',
|
||||||
|
as: 'user'
|
||||||
|
});
|
||||||
|
|
||||||
|
AssetDownload.belongsTo(models.Reseller, {
|
||||||
|
foreignKey: 'resellerId',
|
||||||
|
as: 'reseller'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return AssetDownload;
|
||||||
|
};
|
||||||
78
src/models/AuditLog.js
Normal file
78
src/models/AuditLog.js
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
module.exports = (sequelize, DataTypes) => {
|
||||||
|
const AuditLog = sequelize.define('AuditLog', {
|
||||||
|
id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true
|
||||||
|
},
|
||||||
|
userId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: true,
|
||||||
|
references: {
|
||||||
|
model: 'users',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
resource: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
resourceId: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
oldValues: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
newValues: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
ipAddress: {
|
||||||
|
type: DataTypes.INET,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
userAgent: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
defaultValue: {}
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
tableName: 'audit_logs',
|
||||||
|
indexes: [
|
||||||
|
{
|
||||||
|
fields: ['user_id']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['action']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['resource']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['resource_id']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['created_at']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Class methods
|
||||||
|
AuditLog.associate = function(models) {
|
||||||
|
AuditLog.belongsTo(models.User, {
|
||||||
|
foreignKey: 'userId',
|
||||||
|
as: 'user'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return AuditLog;
|
||||||
|
};
|
||||||
194
src/models/Certificate.js
Normal file
194
src/models/Certificate.js
Normal file
@ -0,0 +1,194 @@
|
|||||||
|
module.exports = (sequelize, DataTypes) => {
|
||||||
|
const Certificate = sequelize.define('Certificate', {
|
||||||
|
id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true
|
||||||
|
},
|
||||||
|
certificateNumber: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
unique: true
|
||||||
|
},
|
||||||
|
courseId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
references: {
|
||||||
|
model: 'courses',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
userId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
references: {
|
||||||
|
model: 'users',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
resellerId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: true,
|
||||||
|
references: {
|
||||||
|
model: 'resellers',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enrollmentId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
references: {
|
||||||
|
model: 'course_enrollments',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
issuedAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: DataTypes.NOW
|
||||||
|
},
|
||||||
|
expiresAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
score: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
grade: {
|
||||||
|
type: DataTypes.ENUM('A+', 'A', 'B+', 'B', 'C+', 'C', 'Pass', 'Fail'),
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
type: DataTypes.ENUM('active', 'expired', 'revoked', 'suspended'),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'active'
|
||||||
|
},
|
||||||
|
certificatePath: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: true // Path to generated PDF certificate
|
||||||
|
},
|
||||||
|
badgeData: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
defaultValue: {} // Badge/credential data for digital badges
|
||||||
|
},
|
||||||
|
verificationCode: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false // Unique code for certificate verification
|
||||||
|
},
|
||||||
|
issuedBy: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: true,
|
||||||
|
references: {
|
||||||
|
model: 'users',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
revokedAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
revokedBy: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: true,
|
||||||
|
references: {
|
||||||
|
model: 'users',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
revocationReason: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
defaultValue: {}
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
tableName: 'certificates',
|
||||||
|
indexes: [
|
||||||
|
{
|
||||||
|
unique: true,
|
||||||
|
fields: ['certificate_number']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['course_id']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['user_id']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['reseller_id']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['status']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['issued_at']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['expires_at']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['verification_code']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Instance methods
|
||||||
|
Certificate.prototype.isValid = function() {
|
||||||
|
if (this.status !== 'active') return false;
|
||||||
|
if (this.expiresAt && new Date() > this.expiresAt) return false;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
Certificate.prototype.isExpired = function() {
|
||||||
|
return this.expiresAt && new Date() > this.expiresAt;
|
||||||
|
};
|
||||||
|
|
||||||
|
Certificate.prototype.getCertificateUrl = function() {
|
||||||
|
if (this.certificatePath) {
|
||||||
|
return `/uploads/certificates/${this.certificatePath}`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
Certificate.prototype.getVerificationUrl = function() {
|
||||||
|
return `/verify-certificate/${this.verificationCode}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Class methods
|
||||||
|
Certificate.associate = function(models) {
|
||||||
|
Certificate.belongsTo(models.Course, {
|
||||||
|
foreignKey: 'courseId',
|
||||||
|
as: 'course'
|
||||||
|
});
|
||||||
|
|
||||||
|
Certificate.belongsTo(models.User, {
|
||||||
|
foreignKey: 'userId',
|
||||||
|
as: 'user'
|
||||||
|
});
|
||||||
|
|
||||||
|
Certificate.belongsTo(models.Reseller, {
|
||||||
|
foreignKey: 'resellerId',
|
||||||
|
as: 'reseller'
|
||||||
|
});
|
||||||
|
|
||||||
|
Certificate.belongsTo(models.CourseEnrollment, {
|
||||||
|
foreignKey: 'enrollmentId',
|
||||||
|
as: 'enrollment'
|
||||||
|
});
|
||||||
|
|
||||||
|
Certificate.belongsTo(models.User, {
|
||||||
|
foreignKey: 'issuedBy',
|
||||||
|
as: 'issuer'
|
||||||
|
});
|
||||||
|
|
||||||
|
Certificate.belongsTo(models.User, {
|
||||||
|
foreignKey: 'revokedBy',
|
||||||
|
as: 'revoker'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return Certificate;
|
||||||
|
};
|
||||||
128
src/models/Commission.js
Normal file
128
src/models/Commission.js
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
module.exports = (sequelize, DataTypes) => {
|
||||||
|
const Commission = sequelize.define('Commission', {
|
||||||
|
id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true
|
||||||
|
},
|
||||||
|
resellerId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
references: {
|
||||||
|
model: 'resellers',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
customerId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: true,
|
||||||
|
references: {
|
||||||
|
model: 'customers',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
orderId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: true,
|
||||||
|
references: {
|
||||||
|
model: 'orders',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
invoiceId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: true,
|
||||||
|
references: {
|
||||||
|
model: 'invoices',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
commissionType: {
|
||||||
|
type: DataTypes.ENUM('sale', 'recurring', 'bonus', 'penalty'),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'sale'
|
||||||
|
},
|
||||||
|
baseAmount: {
|
||||||
|
type: DataTypes.DECIMAL(15, 2),
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
commissionRate: {
|
||||||
|
type: DataTypes.DECIMAL(5, 2),
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
commissionAmount: {
|
||||||
|
type: DataTypes.DECIMAL(15, 2),
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
currency: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'INR'
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
type: DataTypes.ENUM('pending', 'approved', 'paid', 'disputed', 'cancelled'),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'pending'
|
||||||
|
},
|
||||||
|
period: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
paidAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
notes: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
defaultValue: {}
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
tableName: 'commissions',
|
||||||
|
indexes: [
|
||||||
|
{
|
||||||
|
fields: ['reseller_id']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['customer_id']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['status']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['commission_type']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['created_at']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Class methods
|
||||||
|
Commission.associate = function(models) {
|
||||||
|
Commission.belongsTo(models.Reseller, {
|
||||||
|
foreignKey: 'resellerId',
|
||||||
|
as: 'reseller'
|
||||||
|
});
|
||||||
|
|
||||||
|
Commission.belongsTo(models.Customer, {
|
||||||
|
foreignKey: 'customerId',
|
||||||
|
as: 'customer'
|
||||||
|
});
|
||||||
|
|
||||||
|
Commission.belongsTo(models.Order, {
|
||||||
|
foreignKey: 'orderId',
|
||||||
|
as: 'order'
|
||||||
|
});
|
||||||
|
|
||||||
|
Commission.belongsTo(models.Invoice, {
|
||||||
|
foreignKey: 'invoiceId',
|
||||||
|
as: 'invoice'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return Commission;
|
||||||
|
};
|
||||||
194
src/models/Course.js
Normal file
194
src/models/Course.js
Normal file
@ -0,0 +1,194 @@
|
|||||||
|
module.exports = (sequelize, DataTypes) => {
|
||||||
|
const Course = sequelize.define('Course', {
|
||||||
|
id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
category: {
|
||||||
|
type: DataTypes.ENUM('product_training', 'sales_training', 'technical_certification', 'compliance', 'onboarding', 'other'),
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
level: {
|
||||||
|
type: DataTypes.ENUM('beginner', 'intermediate', 'advanced'),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'beginner'
|
||||||
|
},
|
||||||
|
duration: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: true // Duration in minutes
|
||||||
|
},
|
||||||
|
estimatedHours: {
|
||||||
|
type: DataTypes.DECIMAL(4, 2),
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
thumbnailPath: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
videoPath: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
materials: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
defaultValue: [] // Array of material objects with type, title, path
|
||||||
|
},
|
||||||
|
prerequisites: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
defaultValue: [] // Array of prerequisite course IDs
|
||||||
|
},
|
||||||
|
learningObjectives: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
defaultValue: []
|
||||||
|
},
|
||||||
|
accessLevel: {
|
||||||
|
type: DataTypes.ENUM('public', 'reseller_only', 'tier_specific', 'admin_only'),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'reseller_only'
|
||||||
|
},
|
||||||
|
tierAccess: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
defaultValue: [] // Array of tiers that can access this course
|
||||||
|
},
|
||||||
|
isRequired: {
|
||||||
|
type: DataTypes.BOOLEAN,
|
||||||
|
defaultValue: false
|
||||||
|
},
|
||||||
|
hasCertificate: {
|
||||||
|
type: DataTypes.BOOLEAN,
|
||||||
|
defaultValue: false
|
||||||
|
},
|
||||||
|
certificateTemplate: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: true // Path to certificate template
|
||||||
|
},
|
||||||
|
passingScore: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: true,
|
||||||
|
defaultValue: 80 // Percentage
|
||||||
|
},
|
||||||
|
maxAttempts: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: true,
|
||||||
|
defaultValue: 3
|
||||||
|
},
|
||||||
|
validityPeriod: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: true // Validity in days
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
type: DataTypes.ENUM('draft', 'active', 'archived', 'maintenance'),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'draft'
|
||||||
|
},
|
||||||
|
tags: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
defaultValue: []
|
||||||
|
},
|
||||||
|
createdBy: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: true,
|
||||||
|
references: {
|
||||||
|
model: 'users',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
updatedBy: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: true,
|
||||||
|
references: {
|
||||||
|
model: 'users',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
defaultValue: {}
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
tableName: 'courses',
|
||||||
|
indexes: [
|
||||||
|
{
|
||||||
|
fields: ['category']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['level']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['status']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['access_level']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['is_required']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['has_certificate']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Instance methods
|
||||||
|
Course.prototype.isAccessibleBy = function(userTier) {
|
||||||
|
if (this.accessLevel === 'public') return true;
|
||||||
|
if (this.accessLevel === 'reseller_only') return true;
|
||||||
|
if (this.accessLevel === 'tier_specific') {
|
||||||
|
return this.tierAccess.includes(userTier);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
Course.prototype.getThumbnailUrl = function() {
|
||||||
|
if (this.thumbnailPath) {
|
||||||
|
return `/uploads/courses/thumbnails/${this.thumbnailPath}`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
Course.prototype.getVideoUrl = function() {
|
||||||
|
if (this.videoPath) {
|
||||||
|
return `/uploads/courses/videos/${this.videoPath}`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Class methods
|
||||||
|
Course.associate = function(models) {
|
||||||
|
Course.belongsTo(models.User, {
|
||||||
|
foreignKey: 'createdBy',
|
||||||
|
as: 'creator'
|
||||||
|
});
|
||||||
|
|
||||||
|
Course.belongsTo(models.User, {
|
||||||
|
foreignKey: 'updatedBy',
|
||||||
|
as: 'updater'
|
||||||
|
});
|
||||||
|
|
||||||
|
Course.hasMany(models.CourseEnrollment, {
|
||||||
|
foreignKey: 'courseId',
|
||||||
|
as: 'enrollments'
|
||||||
|
});
|
||||||
|
|
||||||
|
Course.hasMany(models.CourseModule, {
|
||||||
|
foreignKey: 'courseId',
|
||||||
|
as: 'modules'
|
||||||
|
});
|
||||||
|
|
||||||
|
Course.hasMany(models.Certificate, {
|
||||||
|
foreignKey: 'courseId',
|
||||||
|
as: 'certificates'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return Course;
|
||||||
|
};
|
||||||
173
src/models/CourseEnrollment.js
Normal file
173
src/models/CourseEnrollment.js
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
module.exports = (sequelize, DataTypes) => {
|
||||||
|
const CourseEnrollment = sequelize.define('CourseEnrollment', {
|
||||||
|
id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true
|
||||||
|
},
|
||||||
|
courseId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
references: {
|
||||||
|
model: 'courses',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
userId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
references: {
|
||||||
|
model: 'users',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
resellerId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: true,
|
||||||
|
references: {
|
||||||
|
model: 'resellers',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enrolledAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: DataTypes.NOW
|
||||||
|
},
|
||||||
|
startedAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
completedAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
lastAccessedAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
type: DataTypes.ENUM('enrolled', 'in_progress', 'completed', 'failed', 'expired', 'cancelled'),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'enrolled'
|
||||||
|
},
|
||||||
|
progress: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 0, // Percentage 0-100
|
||||||
|
validate: {
|
||||||
|
min: 0,
|
||||||
|
max: 100
|
||||||
|
}
|
||||||
|
},
|
||||||
|
timeSpent: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 0 // Time in minutes
|
||||||
|
},
|
||||||
|
attempts: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 0
|
||||||
|
},
|
||||||
|
bestScore: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: true // Best quiz/test score
|
||||||
|
},
|
||||||
|
currentScore: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: true // Current/latest score
|
||||||
|
},
|
||||||
|
moduleProgress: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
defaultValue: {} // Object tracking progress per module
|
||||||
|
},
|
||||||
|
notes: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
expiresAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
defaultValue: {}
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
tableName: 'course_enrollments',
|
||||||
|
indexes: [
|
||||||
|
{
|
||||||
|
fields: ['course_id']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['user_id']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['reseller_id']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['status']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['enrolled_at']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['completed_at']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
unique: true,
|
||||||
|
fields: ['course_id', 'user_id']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Instance methods
|
||||||
|
CourseEnrollment.prototype.isCompleted = function() {
|
||||||
|
return this.status === 'completed';
|
||||||
|
};
|
||||||
|
|
||||||
|
CourseEnrollment.prototype.isExpired = function() {
|
||||||
|
return this.expiresAt && new Date() > this.expiresAt;
|
||||||
|
};
|
||||||
|
|
||||||
|
CourseEnrollment.prototype.canRetake = function(maxAttempts) {
|
||||||
|
return !maxAttempts || this.attempts < maxAttempts;
|
||||||
|
};
|
||||||
|
|
||||||
|
CourseEnrollment.prototype.updateProgress = function(moduleId, moduleProgress) {
|
||||||
|
const progress = { ...this.moduleProgress };
|
||||||
|
progress[moduleId] = moduleProgress;
|
||||||
|
|
||||||
|
// Calculate overall progress
|
||||||
|
const moduleKeys = Object.keys(progress);
|
||||||
|
const totalProgress = moduleKeys.reduce((sum, key) => sum + (progress[key] || 0), 0);
|
||||||
|
const overallProgress = moduleKeys.length > 0 ? Math.round(totalProgress / moduleKeys.length) : 0;
|
||||||
|
|
||||||
|
return this.update({
|
||||||
|
moduleProgress: progress,
|
||||||
|
progress: overallProgress,
|
||||||
|
lastAccessedAt: new Date()
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Class methods
|
||||||
|
CourseEnrollment.associate = function(models) {
|
||||||
|
CourseEnrollment.belongsTo(models.Course, {
|
||||||
|
foreignKey: 'courseId',
|
||||||
|
as: 'course'
|
||||||
|
});
|
||||||
|
|
||||||
|
CourseEnrollment.belongsTo(models.User, {
|
||||||
|
foreignKey: 'userId',
|
||||||
|
as: 'user'
|
||||||
|
});
|
||||||
|
|
||||||
|
CourseEnrollment.belongsTo(models.Reseller, {
|
||||||
|
foreignKey: 'resellerId',
|
||||||
|
as: 'reseller'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return CourseEnrollment;
|
||||||
|
};
|
||||||
246
src/models/Customer.js
Normal file
246
src/models/Customer.js
Normal file
@ -0,0 +1,246 @@
|
|||||||
|
module.exports = (sequelize, DataTypes) => {
|
||||||
|
const Customer = sequelize.define('Customer', {
|
||||||
|
id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true
|
||||||
|
},
|
||||||
|
resellerId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
references: {
|
||||||
|
model: 'resellers',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
companyName: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
validate: {
|
||||||
|
len: [2, 100]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
contactPerson: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
validate: {
|
||||||
|
len: [2, 100]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
email: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
validate: {
|
||||||
|
isEmail: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
phone: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: true,
|
||||||
|
validate: {
|
||||||
|
is: /^[\+]?[1-9][\d]{0,15}$/
|
||||||
|
}
|
||||||
|
},
|
||||||
|
address: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
allowNull: false,
|
||||||
|
validate: {
|
||||||
|
hasRequiredFields(value) {
|
||||||
|
if (!value.street || !value.city || !value.state || !value.country || !value.zipCode) {
|
||||||
|
throw new Error('Address must include street, city, state, country, and zipCode');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
industry: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
companySize: {
|
||||||
|
type: DataTypes.ENUM('startup', 'small', 'medium', 'large', 'enterprise'),
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
type: DataTypes.ENUM('active', 'inactive', 'suspended', 'pending_setup'),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'pending_setup'
|
||||||
|
},
|
||||||
|
billingAddress: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
paymentMethod: {
|
||||||
|
type: DataTypes.ENUM('credit_card', 'bank_transfer', 'invoice', 'prepaid'),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'invoice'
|
||||||
|
},
|
||||||
|
creditLimit: {
|
||||||
|
type: DataTypes.DECIMAL(12, 2),
|
||||||
|
allowNull: true,
|
||||||
|
defaultValue: 0
|
||||||
|
},
|
||||||
|
currentBalance: {
|
||||||
|
type: DataTypes.DECIMAL(12, 2),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 0
|
||||||
|
},
|
||||||
|
currency: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'INR'
|
||||||
|
},
|
||||||
|
taxId: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
gstNumber: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: true,
|
||||||
|
validate: {
|
||||||
|
is: /^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z]{1}[1-9A-Z]{1}Z[0-9A-Z]{1}$/
|
||||||
|
}
|
||||||
|
},
|
||||||
|
contractDetails: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
defaultValue: {}
|
||||||
|
},
|
||||||
|
tags: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
defaultValue: []
|
||||||
|
},
|
||||||
|
notes: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
onboardingStatus: {
|
||||||
|
type: DataTypes.ENUM('not_started', 'in_progress', 'completed'),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'not_started'
|
||||||
|
},
|
||||||
|
lastActivity: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
defaultValue: {}
|
||||||
|
},
|
||||||
|
createdBy: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: true,
|
||||||
|
references: {
|
||||||
|
model: 'users',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
tableName: 'customers',
|
||||||
|
indexes: [
|
||||||
|
{
|
||||||
|
fields: ['reseller_id']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['email']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['status']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['company_name']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['onboarding_status']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['last_activity']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Instance methods
|
||||||
|
Customer.prototype.updateBalance = async function(amount, operation = 'add') {
|
||||||
|
const currentBalance = parseFloat(this.currentBalance) || 0;
|
||||||
|
const changeAmount = parseFloat(amount);
|
||||||
|
|
||||||
|
let newBalance;
|
||||||
|
if (operation === 'add') {
|
||||||
|
newBalance = currentBalance + changeAmount;
|
||||||
|
} else if (operation === 'subtract') {
|
||||||
|
newBalance = currentBalance - changeAmount;
|
||||||
|
} else {
|
||||||
|
newBalance = changeAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.update({
|
||||||
|
currentBalance: newBalance,
|
||||||
|
lastActivity: new Date()
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
Customer.prototype.isWithinCreditLimit = function(amount) {
|
||||||
|
const totalOwed = parseFloat(this.currentBalance) + parseFloat(amount);
|
||||||
|
return totalOwed <= parseFloat(this.creditLimit);
|
||||||
|
};
|
||||||
|
|
||||||
|
Customer.prototype.getOutstandingBalance = function() {
|
||||||
|
return Math.max(0, parseFloat(this.currentBalance));
|
||||||
|
};
|
||||||
|
|
||||||
|
Customer.prototype.isActive = function() {
|
||||||
|
return this.status === 'active';
|
||||||
|
};
|
||||||
|
|
||||||
|
Customer.prototype.updateActivity = async function() {
|
||||||
|
return this.update({ lastActivity: new Date() });
|
||||||
|
};
|
||||||
|
|
||||||
|
Customer.prototype.addTag = async function(tag) {
|
||||||
|
const currentTags = this.tags || [];
|
||||||
|
if (!currentTags.includes(tag)) {
|
||||||
|
currentTags.push(tag);
|
||||||
|
return this.update({ tags: currentTags });
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
};
|
||||||
|
|
||||||
|
Customer.prototype.removeTag = async function(tag) {
|
||||||
|
const currentTags = this.tags || [];
|
||||||
|
const updatedTags = currentTags.filter(t => t !== tag);
|
||||||
|
return this.update({ tags: updatedTags });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Class methods
|
||||||
|
Customer.associate = function(models) {
|
||||||
|
Customer.belongsTo(models.Reseller, {
|
||||||
|
foreignKey: 'resellerId',
|
||||||
|
as: 'reseller'
|
||||||
|
});
|
||||||
|
|
||||||
|
Customer.belongsTo(models.User, {
|
||||||
|
foreignKey: 'createdBy',
|
||||||
|
as: 'creator'
|
||||||
|
});
|
||||||
|
|
||||||
|
Customer.hasMany(models.Order, {
|
||||||
|
foreignKey: 'customerId',
|
||||||
|
as: 'orders'
|
||||||
|
});
|
||||||
|
|
||||||
|
Customer.hasMany(models.CustomerService, {
|
||||||
|
foreignKey: 'customerId',
|
||||||
|
as: 'services'
|
||||||
|
});
|
||||||
|
|
||||||
|
Customer.hasMany(models.Invoice, {
|
||||||
|
foreignKey: 'customerId',
|
||||||
|
as: 'invoices'
|
||||||
|
});
|
||||||
|
|
||||||
|
Customer.hasMany(models.UsageRecord, {
|
||||||
|
foreignKey: 'customerId',
|
||||||
|
as: 'usageRecords'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return Customer;
|
||||||
|
};
|
||||||
265
src/models/CustomerService.js
Normal file
265
src/models/CustomerService.js
Normal file
@ -0,0 +1,265 @@
|
|||||||
|
module.exports = (sequelize, DataTypes) => {
|
||||||
|
const CustomerService = sequelize.define('CustomerService', {
|
||||||
|
id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true
|
||||||
|
},
|
||||||
|
customerId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
references: {
|
||||||
|
model: 'customers',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
productId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
references: {
|
||||||
|
model: 'products',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
serviceInstanceId: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
unique: true
|
||||||
|
},
|
||||||
|
serviceName: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
type: DataTypes.ENUM('provisioning', 'active', 'suspended', 'terminated', 'error'),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'provisioning'
|
||||||
|
},
|
||||||
|
configuration: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
defaultValue: {}
|
||||||
|
},
|
||||||
|
specifications: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
defaultValue: {}
|
||||||
|
},
|
||||||
|
region: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
zone: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
pricing: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: {
|
||||||
|
basePrice: 0,
|
||||||
|
finalPrice: 0,
|
||||||
|
currency: 'INR'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
billingCycle: {
|
||||||
|
type: DataTypes.ENUM('hourly', 'daily', 'weekly', 'monthly', 'quarterly', 'yearly'),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'monthly'
|
||||||
|
},
|
||||||
|
nextBillingDate: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
lastBilledDate: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
activatedAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
suspendedAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
terminatedAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
autoRenewal: {
|
||||||
|
type: DataTypes.BOOLEAN,
|
||||||
|
defaultValue: true
|
||||||
|
},
|
||||||
|
contractPeriod: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: true // in months
|
||||||
|
},
|
||||||
|
contractEndDate: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
usageMetrics: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
defaultValue: {}
|
||||||
|
},
|
||||||
|
limits: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
defaultValue: {}
|
||||||
|
},
|
||||||
|
alerts: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
defaultValue: []
|
||||||
|
},
|
||||||
|
tags: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
defaultValue: []
|
||||||
|
},
|
||||||
|
notes: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
defaultValue: {}
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
tableName: 'customer_services',
|
||||||
|
indexes: [
|
||||||
|
{
|
||||||
|
unique: true,
|
||||||
|
fields: ['service_instance_id']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['customer_id']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['product_id']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['status']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['region']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['next_billing_date']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['activated_at']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Instance methods
|
||||||
|
CustomerService.prototype.activate = async function() {
|
||||||
|
return this.update({
|
||||||
|
status: 'active',
|
||||||
|
activatedAt: new Date()
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
CustomerService.prototype.suspend = async function(reason = null) {
|
||||||
|
const updates = {
|
||||||
|
status: 'suspended',
|
||||||
|
suspendedAt: new Date()
|
||||||
|
};
|
||||||
|
|
||||||
|
if (reason) {
|
||||||
|
updates.notes = (this.notes || '') + `\nSuspended: ${reason}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.update(updates);
|
||||||
|
};
|
||||||
|
|
||||||
|
CustomerService.prototype.terminate = async function(reason = null) {
|
||||||
|
const updates = {
|
||||||
|
status: 'terminated',
|
||||||
|
terminatedAt: new Date()
|
||||||
|
};
|
||||||
|
|
||||||
|
if (reason) {
|
||||||
|
updates.notes = (this.notes || '') + `\nTerminated: ${reason}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.update(updates);
|
||||||
|
};
|
||||||
|
|
||||||
|
CustomerService.prototype.updateUsage = async function(metrics) {
|
||||||
|
const currentMetrics = this.usageMetrics || {};
|
||||||
|
const updatedMetrics = { ...currentMetrics, ...metrics };
|
||||||
|
|
||||||
|
return this.update({
|
||||||
|
usageMetrics: updatedMetrics,
|
||||||
|
updatedAt: new Date()
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
CustomerService.prototype.isActive = function() {
|
||||||
|
return this.status === 'active';
|
||||||
|
};
|
||||||
|
|
||||||
|
CustomerService.prototype.isExpired = function() {
|
||||||
|
return this.contractEndDate && new Date() > new Date(this.contractEndDate);
|
||||||
|
};
|
||||||
|
|
||||||
|
CustomerService.prototype.getDaysUntilExpiry = function() {
|
||||||
|
if (!this.contractEndDate) return null;
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const expiry = new Date(this.contractEndDate);
|
||||||
|
const diffTime = expiry - now;
|
||||||
|
return Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||||
|
};
|
||||||
|
|
||||||
|
CustomerService.prototype.calculateNextBillingDate = function() {
|
||||||
|
const lastBilled = this.lastBilledDate || this.activatedAt || new Date();
|
||||||
|
const nextBilling = new Date(lastBilled);
|
||||||
|
|
||||||
|
switch (this.billingCycle) {
|
||||||
|
case 'hourly':
|
||||||
|
nextBilling.setHours(nextBilling.getHours() + 1);
|
||||||
|
break;
|
||||||
|
case 'daily':
|
||||||
|
nextBilling.setDate(nextBilling.getDate() + 1);
|
||||||
|
break;
|
||||||
|
case 'weekly':
|
||||||
|
nextBilling.setDate(nextBilling.getDate() + 7);
|
||||||
|
break;
|
||||||
|
case 'monthly':
|
||||||
|
nextBilling.setMonth(nextBilling.getMonth() + 1);
|
||||||
|
break;
|
||||||
|
case 'quarterly':
|
||||||
|
nextBilling.setMonth(nextBilling.getMonth() + 3);
|
||||||
|
break;
|
||||||
|
case 'yearly':
|
||||||
|
nextBilling.setFullYear(nextBilling.getFullYear() + 1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextBilling;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Class methods
|
||||||
|
CustomerService.associate = function(models) {
|
||||||
|
CustomerService.belongsTo(models.Customer, {
|
||||||
|
foreignKey: 'customerId',
|
||||||
|
as: 'customer'
|
||||||
|
});
|
||||||
|
|
||||||
|
CustomerService.belongsTo(models.Product, {
|
||||||
|
foreignKey: 'productId',
|
||||||
|
as: 'product'
|
||||||
|
});
|
||||||
|
|
||||||
|
CustomerService.hasMany(models.UsageRecord, {
|
||||||
|
foreignKey: 'serviceId',
|
||||||
|
as: 'usageRecords'
|
||||||
|
});
|
||||||
|
|
||||||
|
CustomerService.hasMany(models.ServiceAlert, {
|
||||||
|
foreignKey: 'serviceId',
|
||||||
|
as: 'serviceAlerts'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return CustomerService;
|
||||||
|
};
|
||||||
198
src/models/Instance.js
Normal file
198
src/models/Instance.js
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
module.exports = (sequelize, DataTypes) => {
|
||||||
|
const Instance = sequelize.define('Instance', {
|
||||||
|
id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true
|
||||||
|
},
|
||||||
|
customerId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
references: {
|
||||||
|
model: 'customers',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
resellerId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
references: {
|
||||||
|
model: 'resellers',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
instanceId: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: true, // External provider instance ID
|
||||||
|
unique: true
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
type: DataTypes.ENUM('compute', 'storage', 'network', 'database'),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'compute'
|
||||||
|
},
|
||||||
|
template: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: true // OS template or image
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false // e.g., 't2.micro', 'small', 'medium'
|
||||||
|
},
|
||||||
|
region: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
zone: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
type: DataTypes.ENUM('pending', 'creating', 'running', 'stopped', 'stopping', 'starting', 'failed', 'terminated'),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'pending'
|
||||||
|
},
|
||||||
|
publicIp: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
privateIp: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
specifications: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
defaultValue: {
|
||||||
|
cpu: 1,
|
||||||
|
memory: 1024,
|
||||||
|
storage: 20,
|
||||||
|
bandwidth: 100
|
||||||
|
}
|
||||||
|
},
|
||||||
|
configuration: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
defaultValue: {}
|
||||||
|
},
|
||||||
|
credentials: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
defaultValue: {} // Encrypted credentials
|
||||||
|
},
|
||||||
|
monitoring: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
defaultValue: {
|
||||||
|
enabled: true,
|
||||||
|
alerts: []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
backups: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
defaultValue: {
|
||||||
|
enabled: false,
|
||||||
|
schedule: null,
|
||||||
|
retention: 7
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tags: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
defaultValue: []
|
||||||
|
},
|
||||||
|
provisionedAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
lastStarted: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
lastStopped: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
defaultValue: {}
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
tableName: 'instances',
|
||||||
|
indexes: [
|
||||||
|
{
|
||||||
|
fields: ['customer_id']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['reseller_id']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['status']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['type']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['region']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
unique: true,
|
||||||
|
fields: ['instance_id'],
|
||||||
|
where: {
|
||||||
|
instance_id: {
|
||||||
|
[sequelize.Sequelize.Op.ne]: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Instance methods
|
||||||
|
Instance.prototype.isRunning = function() {
|
||||||
|
return this.status === 'running';
|
||||||
|
};
|
||||||
|
|
||||||
|
Instance.prototype.canStart = function() {
|
||||||
|
return ['stopped', 'failed'].includes(this.status);
|
||||||
|
};
|
||||||
|
|
||||||
|
Instance.prototype.canStop = function() {
|
||||||
|
return ['running'].includes(this.status);
|
||||||
|
};
|
||||||
|
|
||||||
|
Instance.prototype.canTerminate = function() {
|
||||||
|
return !['terminated', 'creating'].includes(this.status);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Class methods
|
||||||
|
Instance.associate = function(models) {
|
||||||
|
Instance.belongsTo(models.Customer, {
|
||||||
|
foreignKey: 'customerId',
|
||||||
|
as: 'customer'
|
||||||
|
});
|
||||||
|
|
||||||
|
Instance.belongsTo(models.Reseller, {
|
||||||
|
foreignKey: 'resellerId',
|
||||||
|
as: 'reseller'
|
||||||
|
});
|
||||||
|
|
||||||
|
Instance.hasMany(models.InstanceSnapshot, {
|
||||||
|
foreignKey: 'instanceId',
|
||||||
|
as: 'snapshots'
|
||||||
|
});
|
||||||
|
|
||||||
|
Instance.hasMany(models.InstanceEvent, {
|
||||||
|
foreignKey: 'instanceId',
|
||||||
|
as: 'events'
|
||||||
|
});
|
||||||
|
|
||||||
|
Instance.hasMany(models.UsageRecord, {
|
||||||
|
foreignKey: 'resourceId',
|
||||||
|
as: 'usageRecords',
|
||||||
|
scope: {
|
||||||
|
resourceType: 'instance'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return Instance;
|
||||||
|
};
|
||||||
109
src/models/InstanceEvent.js
Normal file
109
src/models/InstanceEvent.js
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
module.exports = (sequelize, DataTypes) => {
|
||||||
|
const InstanceEvent = sequelize.define('InstanceEvent', {
|
||||||
|
id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true
|
||||||
|
},
|
||||||
|
instanceId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
references: {
|
||||||
|
model: 'instances',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
eventType: {
|
||||||
|
type: DataTypes.ENUM(
|
||||||
|
'created', 'started', 'stopped', 'restarted', 'terminated',
|
||||||
|
'snapshot_created', 'snapshot_deleted', 'backup_created',
|
||||||
|
'configuration_changed', 'status_changed', 'error', 'maintenance'
|
||||||
|
),
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
type: DataTypes.ENUM('pending', 'in_progress', 'completed', 'failed'),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'pending'
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
details: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
defaultValue: {}
|
||||||
|
},
|
||||||
|
triggeredBy: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: true,
|
||||||
|
references: {
|
||||||
|
model: 'users',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
source: {
|
||||||
|
type: DataTypes.ENUM('user', 'system', 'webhook', 'scheduler'),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'user'
|
||||||
|
},
|
||||||
|
startedAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
completedAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
defaultValue: {}
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
tableName: 'instance_events',
|
||||||
|
indexes: [
|
||||||
|
{
|
||||||
|
fields: ['instance_id']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['event_type']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['status']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['source']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['created_at']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Instance methods
|
||||||
|
InstanceEvent.prototype.isCompleted = function() {
|
||||||
|
return ['completed', 'failed'].includes(this.status);
|
||||||
|
};
|
||||||
|
|
||||||
|
InstanceEvent.prototype.getDuration = function() {
|
||||||
|
if (this.startedAt && this.completedAt) {
|
||||||
|
return this.completedAt - this.startedAt;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Class methods
|
||||||
|
InstanceEvent.associate = function(models) {
|
||||||
|
InstanceEvent.belongsTo(models.Instance, {
|
||||||
|
foreignKey: 'instanceId',
|
||||||
|
as: 'instance'
|
||||||
|
});
|
||||||
|
|
||||||
|
InstanceEvent.belongsTo(models.User, {
|
||||||
|
foreignKey: 'triggeredBy',
|
||||||
|
as: 'user'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return InstanceEvent;
|
||||||
|
};
|
||||||
101
src/models/InstanceSnapshot.js
Normal file
101
src/models/InstanceSnapshot.js
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
module.exports = (sequelize, DataTypes) => {
|
||||||
|
const InstanceSnapshot = sequelize.define('InstanceSnapshot', {
|
||||||
|
id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true
|
||||||
|
},
|
||||||
|
instanceId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
references: {
|
||||||
|
model: 'instances',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
snapshotId: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: true, // External provider snapshot ID
|
||||||
|
unique: true
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
type: DataTypes.ENUM('creating', 'available', 'deleting', 'failed'),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'creating'
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
type: DataTypes.BIGINT,
|
||||||
|
allowNull: true // Size in bytes
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
type: DataTypes.ENUM('manual', 'automatic', 'backup'),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'manual'
|
||||||
|
},
|
||||||
|
retentionDays: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: true,
|
||||||
|
defaultValue: 30
|
||||||
|
},
|
||||||
|
expiresAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
defaultValue: {}
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
tableName: 'instance_snapshots',
|
||||||
|
indexes: [
|
||||||
|
{
|
||||||
|
fields: ['instance_id']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['status']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['type']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['expires_at']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
unique: true,
|
||||||
|
fields: ['snapshot_id'],
|
||||||
|
where: {
|
||||||
|
snapshot_id: {
|
||||||
|
[sequelize.Sequelize.Op.ne]: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Instance methods
|
||||||
|
InstanceSnapshot.prototype.isExpired = function() {
|
||||||
|
return this.expiresAt && new Date() > this.expiresAt;
|
||||||
|
};
|
||||||
|
|
||||||
|
InstanceSnapshot.prototype.canDelete = function() {
|
||||||
|
return ['available', 'failed'].includes(this.status);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Class methods
|
||||||
|
InstanceSnapshot.associate = function(models) {
|
||||||
|
InstanceSnapshot.belongsTo(models.Instance, {
|
||||||
|
foreignKey: 'instanceId',
|
||||||
|
as: 'instance'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return InstanceSnapshot;
|
||||||
|
};
|
||||||
291
src/models/Invoice.js
Normal file
291
src/models/Invoice.js
Normal file
@ -0,0 +1,291 @@
|
|||||||
|
module.exports = (sequelize, DataTypes) => {
|
||||||
|
const Invoice = sequelize.define('Invoice', {
|
||||||
|
id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true
|
||||||
|
},
|
||||||
|
invoiceNumber: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
unique: true
|
||||||
|
},
|
||||||
|
resellerId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
references: {
|
||||||
|
model: 'resellers',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
customerId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: true,
|
||||||
|
references: {
|
||||||
|
model: 'customers',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
invoiceType: {
|
||||||
|
type: DataTypes.ENUM('service', 'commission', 'adjustment', 'refund'),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'service'
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
type: DataTypes.ENUM('draft', 'sent', 'paid', 'overdue', 'cancelled', 'refunded'),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'draft'
|
||||||
|
},
|
||||||
|
billingPeriod: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
allowNull: false,
|
||||||
|
validate: {
|
||||||
|
hasRequiredFields(value) {
|
||||||
|
if (!value.startDate || !value.endDate) {
|
||||||
|
throw new Error('Billing period must include startDate and endDate');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
subtotal: {
|
||||||
|
type: DataTypes.DECIMAL(15, 2),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 0.00
|
||||||
|
},
|
||||||
|
taxAmount: {
|
||||||
|
type: DataTypes.DECIMAL(15, 2),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 0.00
|
||||||
|
},
|
||||||
|
discountAmount: {
|
||||||
|
type: DataTypes.DECIMAL(15, 2),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 0.00
|
||||||
|
},
|
||||||
|
totalAmount: {
|
||||||
|
type: DataTypes.DECIMAL(15, 2),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 0.00
|
||||||
|
},
|
||||||
|
currency: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'INR'
|
||||||
|
},
|
||||||
|
taxDetails: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
defaultValue: {
|
||||||
|
cgst: 0,
|
||||||
|
sgst: 0,
|
||||||
|
igst: 0,
|
||||||
|
totalTax: 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
billingAddress: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
shippingAddress: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
dueDate: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
paidAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
paidAmount: {
|
||||||
|
type: DataTypes.DECIMAL(15, 2),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 0.00
|
||||||
|
},
|
||||||
|
paymentMethod: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
paymentReference: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
notes: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
terms: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
filePath: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
downloadCount: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
defaultValue: 0
|
||||||
|
},
|
||||||
|
lastDownloadAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
sentAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
remindersSent: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
defaultValue: 0
|
||||||
|
},
|
||||||
|
lastReminderAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
defaultValue: {}
|
||||||
|
},
|
||||||
|
createdBy: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: true,
|
||||||
|
references: {
|
||||||
|
model: 'users',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
tableName: 'invoices',
|
||||||
|
indexes: [
|
||||||
|
{
|
||||||
|
unique: true,
|
||||||
|
fields: ['invoice_number']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['reseller_id']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['customer_id']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['status']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['invoice_type']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['due_date']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['created_at']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Instance methods
|
||||||
|
Invoice.prototype.calculateTotals = function() {
|
||||||
|
const subtotal = parseFloat(this.subtotal) || 0;
|
||||||
|
const taxAmount = parseFloat(this.taxAmount) || 0;
|
||||||
|
const discountAmount = parseFloat(this.discountAmount) || 0;
|
||||||
|
|
||||||
|
const total = subtotal + taxAmount - discountAmount;
|
||||||
|
return parseFloat(total.toFixed(2));
|
||||||
|
};
|
||||||
|
|
||||||
|
Invoice.prototype.markAsPaid = async function(amount, paymentMethod, paymentReference) {
|
||||||
|
const paidAmount = parseFloat(amount);
|
||||||
|
const totalAmount = parseFloat(this.totalAmount);
|
||||||
|
|
||||||
|
const updates = {
|
||||||
|
paidAmount: paidAmount,
|
||||||
|
paidAt: new Date(),
|
||||||
|
paymentMethod,
|
||||||
|
paymentReference
|
||||||
|
};
|
||||||
|
|
||||||
|
if (paidAmount >= totalAmount) {
|
||||||
|
updates.status = 'paid';
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.update(updates);
|
||||||
|
};
|
||||||
|
|
||||||
|
Invoice.prototype.isOverdue = function() {
|
||||||
|
return this.status !== 'paid' && new Date() > new Date(this.dueDate);
|
||||||
|
};
|
||||||
|
|
||||||
|
Invoice.prototype.getDaysOverdue = function() {
|
||||||
|
if (!this.isOverdue()) return 0;
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const dueDate = new Date(this.dueDate);
|
||||||
|
const diffTime = now - dueDate;
|
||||||
|
return Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||||
|
};
|
||||||
|
|
||||||
|
Invoice.prototype.getOutstandingAmount = function() {
|
||||||
|
const totalAmount = parseFloat(this.totalAmount) || 0;
|
||||||
|
const paidAmount = parseFloat(this.paidAmount) || 0;
|
||||||
|
return Math.max(0, totalAmount - paidAmount);
|
||||||
|
};
|
||||||
|
|
||||||
|
Invoice.prototype.generateInvoiceNumber = function() {
|
||||||
|
const date = new Date();
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const timestamp = Date.now().toString().slice(-6);
|
||||||
|
|
||||||
|
return `INV-${year}${month}-${timestamp}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
Invoice.prototype.markAsSent = async function() {
|
||||||
|
return this.update({
|
||||||
|
status: 'sent',
|
||||||
|
sentAt: new Date()
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
Invoice.prototype.addReminder = async function() {
|
||||||
|
return this.update({
|
||||||
|
remindersSent: this.remindersSent + 1,
|
||||||
|
lastReminderAt: new Date()
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
Invoice.prototype.incrementDownload = async function() {
|
||||||
|
return this.update({
|
||||||
|
downloadCount: this.downloadCount + 1,
|
||||||
|
lastDownloadAt: new Date()
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Class methods
|
||||||
|
Invoice.associate = function(models) {
|
||||||
|
Invoice.belongsTo(models.Reseller, {
|
||||||
|
foreignKey: 'resellerId',
|
||||||
|
as: 'reseller'
|
||||||
|
});
|
||||||
|
|
||||||
|
Invoice.belongsTo(models.Customer, {
|
||||||
|
foreignKey: 'customerId',
|
||||||
|
as: 'customer'
|
||||||
|
});
|
||||||
|
|
||||||
|
Invoice.belongsTo(models.User, {
|
||||||
|
foreignKey: 'createdBy',
|
||||||
|
as: 'creator'
|
||||||
|
});
|
||||||
|
|
||||||
|
Invoice.hasMany(models.InvoiceItem, {
|
||||||
|
foreignKey: 'invoiceId',
|
||||||
|
as: 'items'
|
||||||
|
});
|
||||||
|
|
||||||
|
Invoice.hasMany(models.WalletTransaction, {
|
||||||
|
foreignKey: 'reference.invoiceId',
|
||||||
|
as: 'transactions'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return Invoice;
|
||||||
|
};
|
||||||
112
src/models/InvoiceItem.js
Normal file
112
src/models/InvoiceItem.js
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
module.exports = (sequelize, DataTypes) => {
|
||||||
|
const InvoiceItem = sequelize.define('InvoiceItem', {
|
||||||
|
id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true
|
||||||
|
},
|
||||||
|
invoiceId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
references: {
|
||||||
|
model: 'invoices',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
productId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: true,
|
||||||
|
references: {
|
||||||
|
model: 'products',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
serviceId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: true,
|
||||||
|
references: {
|
||||||
|
model: 'customer_services',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
quantity: {
|
||||||
|
type: DataTypes.DECIMAL(15, 4),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 1
|
||||||
|
},
|
||||||
|
unit: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
unitPrice: {
|
||||||
|
type: DataTypes.DECIMAL(15, 2),
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
lineTotal: {
|
||||||
|
type: DataTypes.DECIMAL(15, 2),
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
discountAmount: {
|
||||||
|
type: DataTypes.DECIMAL(15, 2),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 0
|
||||||
|
},
|
||||||
|
taxAmount: {
|
||||||
|
type: DataTypes.DECIMAL(15, 2),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 0
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
defaultValue: {}
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
tableName: 'invoice_items',
|
||||||
|
indexes: [
|
||||||
|
{
|
||||||
|
fields: ['invoice_id']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['product_id']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['service_id']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Instance methods
|
||||||
|
InvoiceItem.prototype.calculateLineTotal = function() {
|
||||||
|
const quantity = parseFloat(this.quantity) || 0;
|
||||||
|
const unitPrice = parseFloat(this.unitPrice) || 0;
|
||||||
|
const discountAmount = parseFloat(this.discountAmount) || 0;
|
||||||
|
const taxAmount = parseFloat(this.taxAmount) || 0;
|
||||||
|
|
||||||
|
const subtotal = quantity * unitPrice;
|
||||||
|
return subtotal - discountAmount + taxAmount;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Class methods
|
||||||
|
InvoiceItem.associate = function(models) {
|
||||||
|
InvoiceItem.belongsTo(models.Invoice, {
|
||||||
|
foreignKey: 'invoiceId',
|
||||||
|
as: 'invoice'
|
||||||
|
});
|
||||||
|
|
||||||
|
InvoiceItem.belongsTo(models.Product, {
|
||||||
|
foreignKey: 'productId',
|
||||||
|
as: 'product'
|
||||||
|
});
|
||||||
|
|
||||||
|
InvoiceItem.belongsTo(models.CustomerService, {
|
||||||
|
foreignKey: 'serviceId',
|
||||||
|
as: 'service'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return InvoiceItem;
|
||||||
|
};
|
||||||
207
src/models/KnowledgeArticle.js
Normal file
207
src/models/KnowledgeArticle.js
Normal file
@ -0,0 +1,207 @@
|
|||||||
|
module.exports = (sequelize, DataTypes) => {
|
||||||
|
const KnowledgeArticle = sequelize.define('KnowledgeArticle', {
|
||||||
|
id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
slug: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
unique: true
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
excerpt: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
type: DataTypes.ENUM('help_article', 'api_documentation', 'tutorial', 'faq', 'troubleshooting', 'best_practices'),
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
category: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
subcategory: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
difficulty: {
|
||||||
|
type: DataTypes.ENUM('beginner', 'intermediate', 'advanced'),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'beginner'
|
||||||
|
},
|
||||||
|
estimatedReadTime: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: true // Reading time in minutes
|
||||||
|
},
|
||||||
|
viewCount: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
defaultValue: 0
|
||||||
|
},
|
||||||
|
helpfulCount: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
defaultValue: 0
|
||||||
|
},
|
||||||
|
notHelpfulCount: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
defaultValue: 0
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
type: DataTypes.ENUM('draft', 'published', 'archived', 'under_review'),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'draft'
|
||||||
|
},
|
||||||
|
featured: {
|
||||||
|
type: DataTypes.BOOLEAN,
|
||||||
|
defaultValue: false
|
||||||
|
},
|
||||||
|
accessLevel: {
|
||||||
|
type: DataTypes.ENUM('public', 'reseller_only', 'tier_specific', 'admin_only'),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'public'
|
||||||
|
},
|
||||||
|
tierAccess: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
defaultValue: []
|
||||||
|
},
|
||||||
|
tags: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
defaultValue: []
|
||||||
|
},
|
||||||
|
relatedArticles: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
defaultValue: [] // Array of related article IDs
|
||||||
|
},
|
||||||
|
attachments: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
defaultValue: [] // Array of attachment objects
|
||||||
|
},
|
||||||
|
lastReviewedAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
publishedAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
createdBy: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: true,
|
||||||
|
references: {
|
||||||
|
model: 'users',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
updatedBy: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: true,
|
||||||
|
references: {
|
||||||
|
model: 'users',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
reviewedBy: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: true,
|
||||||
|
references: {
|
||||||
|
model: 'users',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
defaultValue: {}
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
tableName: 'knowledge_articles',
|
||||||
|
indexes: [
|
||||||
|
{
|
||||||
|
unique: true,
|
||||||
|
fields: ['slug']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['type']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['category']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['status']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['access_level']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['featured']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['published_at']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['view_count']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Instance methods
|
||||||
|
KnowledgeArticle.prototype.isAccessibleBy = function(userTier) {
|
||||||
|
if (this.accessLevel === 'public') return true;
|
||||||
|
if (this.accessLevel === 'reseller_only') return true;
|
||||||
|
if (this.accessLevel === 'tier_specific') {
|
||||||
|
return this.tierAccess.includes(userTier);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
KnowledgeArticle.prototype.isPublished = function() {
|
||||||
|
return this.status === 'published' && this.publishedAt;
|
||||||
|
};
|
||||||
|
|
||||||
|
KnowledgeArticle.prototype.getReadingTime = function() {
|
||||||
|
if (this.estimatedReadTime) return this.estimatedReadTime;
|
||||||
|
|
||||||
|
// Calculate based on content length (average 200 words per minute)
|
||||||
|
const wordCount = this.content.split(/\s+/).length;
|
||||||
|
return Math.ceil(wordCount / 200);
|
||||||
|
};
|
||||||
|
|
||||||
|
KnowledgeArticle.prototype.getHelpfulnessRatio = function() {
|
||||||
|
const total = this.helpfulCount + this.notHelpfulCount;
|
||||||
|
if (total === 0) return 0;
|
||||||
|
return (this.helpfulCount / total) * 100;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Class methods
|
||||||
|
KnowledgeArticle.associate = function(models) {
|
||||||
|
KnowledgeArticle.belongsTo(models.User, {
|
||||||
|
foreignKey: 'createdBy',
|
||||||
|
as: 'author'
|
||||||
|
});
|
||||||
|
|
||||||
|
KnowledgeArticle.belongsTo(models.User, {
|
||||||
|
foreignKey: 'updatedBy',
|
||||||
|
as: 'updater'
|
||||||
|
});
|
||||||
|
|
||||||
|
KnowledgeArticle.belongsTo(models.User, {
|
||||||
|
foreignKey: 'reviewedBy',
|
||||||
|
as: 'reviewer'
|
||||||
|
});
|
||||||
|
|
||||||
|
KnowledgeArticle.hasMany(models.ArticleFeedback, {
|
||||||
|
foreignKey: 'articleId',
|
||||||
|
as: 'feedback'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return KnowledgeArticle;
|
||||||
|
};
|
||||||
162
src/models/LegalAcceptance.js
Normal file
162
src/models/LegalAcceptance.js
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
module.exports = (sequelize, DataTypes) => {
|
||||||
|
const LegalAcceptance = sequelize.define('LegalAcceptance', {
|
||||||
|
id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true
|
||||||
|
},
|
||||||
|
documentId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
references: {
|
||||||
|
model: 'legal_documents',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
userId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
references: {
|
||||||
|
model: 'users',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
resellerId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: true,
|
||||||
|
references: {
|
||||||
|
model: 'resellers',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
acceptanceMethod: {
|
||||||
|
type: DataTypes.ENUM('click_through', 'signature', 'upload', 'email', 'physical'),
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
acceptedAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: DataTypes.NOW
|
||||||
|
},
|
||||||
|
ipAddress: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
userAgent: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
signatureData: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
allowNull: true // For digital signatures
|
||||||
|
},
|
||||||
|
uploadedFile: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: true // Path to uploaded compliance document
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
type: DataTypes.ENUM('accepted', 'pending_verification', 'verified', 'rejected', 'expired'),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'accepted'
|
||||||
|
},
|
||||||
|
verifiedBy: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: true,
|
||||||
|
references: {
|
||||||
|
model: 'users',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
verifiedAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
rejectionReason: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
expiresAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
notes: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
defaultValue: {}
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
tableName: 'legal_acceptances',
|
||||||
|
indexes: [
|
||||||
|
{
|
||||||
|
fields: ['document_id']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['user_id']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['reseller_id']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['status']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['accepted_at']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['expires_at']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
unique: true,
|
||||||
|
fields: ['document_id', 'user_id', 'reseller_id']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Instance methods
|
||||||
|
LegalAcceptance.prototype.isValid = function() {
|
||||||
|
return this.status === 'accepted' || this.status === 'verified';
|
||||||
|
};
|
||||||
|
|
||||||
|
LegalAcceptance.prototype.isExpired = function() {
|
||||||
|
return this.expiresAt && new Date() > this.expiresAt;
|
||||||
|
};
|
||||||
|
|
||||||
|
LegalAcceptance.prototype.requiresVerification = function() {
|
||||||
|
return this.status === 'pending_verification';
|
||||||
|
};
|
||||||
|
|
||||||
|
LegalAcceptance.prototype.getUploadUrl = function() {
|
||||||
|
if (this.uploadedFile) {
|
||||||
|
return `/uploads/legal/compliance/${this.uploadedFile}`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Class methods
|
||||||
|
LegalAcceptance.associate = function(models) {
|
||||||
|
LegalAcceptance.belongsTo(models.LegalDocument, {
|
||||||
|
foreignKey: 'documentId',
|
||||||
|
as: 'document'
|
||||||
|
});
|
||||||
|
|
||||||
|
LegalAcceptance.belongsTo(models.User, {
|
||||||
|
foreignKey: 'userId',
|
||||||
|
as: 'user'
|
||||||
|
});
|
||||||
|
|
||||||
|
LegalAcceptance.belongsTo(models.Reseller, {
|
||||||
|
foreignKey: 'resellerId',
|
||||||
|
as: 'reseller'
|
||||||
|
});
|
||||||
|
|
||||||
|
LegalAcceptance.belongsTo(models.User, {
|
||||||
|
foreignKey: 'verifiedBy',
|
||||||
|
as: 'verifier'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return LegalAcceptance;
|
||||||
|
};
|
||||||
150
src/models/LegalDocument.js
Normal file
150
src/models/LegalDocument.js
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
module.exports = (sequelize, DataTypes) => {
|
||||||
|
const LegalDocument = sequelize.define('LegalDocument', {
|
||||||
|
id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
type: DataTypes.ENUM('terms_of_service', 'privacy_policy', 'sla', 'compliance', 'agreement', 'other'),
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
version: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: '1.0'
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
filePath: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
fileSize: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
mimeType: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
type: DataTypes.ENUM('draft', 'active', 'archived', 'superseded'),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'draft'
|
||||||
|
},
|
||||||
|
effectiveDate: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
expiryDate: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
requiresAcceptance: {
|
||||||
|
type: DataTypes.BOOLEAN,
|
||||||
|
defaultValue: false
|
||||||
|
},
|
||||||
|
acceptanceType: {
|
||||||
|
type: DataTypes.ENUM('click_through', 'signature', 'upload', 'none'),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'none'
|
||||||
|
},
|
||||||
|
category: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
tags: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
defaultValue: []
|
||||||
|
},
|
||||||
|
createdBy: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: true,
|
||||||
|
references: {
|
||||||
|
model: 'users',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
updatedBy: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: true,
|
||||||
|
references: {
|
||||||
|
model: 'users',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
defaultValue: {}
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
tableName: 'legal_documents',
|
||||||
|
indexes: [
|
||||||
|
{
|
||||||
|
fields: ['type']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['status']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['effective_date']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['requires_acceptance']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
unique: true,
|
||||||
|
fields: ['type', 'version'],
|
||||||
|
where: {
|
||||||
|
status: 'active'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Instance methods
|
||||||
|
LegalDocument.prototype.isActive = function() {
|
||||||
|
const now = new Date();
|
||||||
|
return this.status === 'active' &&
|
||||||
|
(!this.effectiveDate || this.effectiveDate <= now) &&
|
||||||
|
(!this.expiryDate || this.expiryDate > now);
|
||||||
|
};
|
||||||
|
|
||||||
|
LegalDocument.prototype.isExpired = function() {
|
||||||
|
return this.expiryDate && new Date() > this.expiryDate;
|
||||||
|
};
|
||||||
|
|
||||||
|
LegalDocument.prototype.getFileUrl = function() {
|
||||||
|
if (this.filePath) {
|
||||||
|
return `/uploads/legal/${this.filePath}`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Class methods
|
||||||
|
LegalDocument.associate = function(models) {
|
||||||
|
LegalDocument.belongsTo(models.User, {
|
||||||
|
foreignKey: 'createdBy',
|
||||||
|
as: 'creator'
|
||||||
|
});
|
||||||
|
|
||||||
|
LegalDocument.belongsTo(models.User, {
|
||||||
|
foreignKey: 'updatedBy',
|
||||||
|
as: 'updater'
|
||||||
|
});
|
||||||
|
|
||||||
|
LegalDocument.hasMany(models.LegalAcceptance, {
|
||||||
|
foreignKey: 'documentId',
|
||||||
|
as: 'acceptances'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return LegalDocument;
|
||||||
|
};
|
||||||
167
src/models/MarketingAsset.js
Normal file
167
src/models/MarketingAsset.js
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
module.exports = (sequelize, DataTypes) => {
|
||||||
|
const MarketingAsset = sequelize.define('MarketingAsset', {
|
||||||
|
id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
type: DataTypes.ENUM('logo', 'brochure', 'presentation', 'video', 'image', 'document', 'template', 'other'),
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
category: {
|
||||||
|
type: DataTypes.ENUM('sales_collateral', 'pitch_deck', 'email_template', 'brand_assets', 'product_sheets', 'case_studies', 'other'),
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
filePath: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
fileName: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
fileSize: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
mimeType: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
thumbnailPath: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
downloadCount: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
defaultValue: 0
|
||||||
|
},
|
||||||
|
isEditable: {
|
||||||
|
type: DataTypes.BOOLEAN,
|
||||||
|
defaultValue: false
|
||||||
|
},
|
||||||
|
editableFormat: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: true // e.g., 'pptx', 'docx', 'figma'
|
||||||
|
},
|
||||||
|
accessLevel: {
|
||||||
|
type: DataTypes.ENUM('public', 'reseller_only', 'tier_specific', 'admin_only'),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'reseller_only'
|
||||||
|
},
|
||||||
|
tierAccess: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
defaultValue: [] // Array of tiers that can access this asset
|
||||||
|
},
|
||||||
|
tags: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
defaultValue: []
|
||||||
|
},
|
||||||
|
version: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
defaultValue: '1.0'
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
type: DataTypes.ENUM('active', 'archived', 'draft'),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'active'
|
||||||
|
},
|
||||||
|
expiryDate: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
createdBy: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: true,
|
||||||
|
references: {
|
||||||
|
model: 'users',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
updatedBy: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: true,
|
||||||
|
references: {
|
||||||
|
model: 'users',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
defaultValue: {}
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
tableName: 'marketing_assets',
|
||||||
|
indexes: [
|
||||||
|
{
|
||||||
|
fields: ['type']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['category']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['status']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['access_level']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['created_at']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Instance methods
|
||||||
|
MarketingAsset.prototype.isAccessibleBy = function(userTier) {
|
||||||
|
if (this.accessLevel === 'public') return true;
|
||||||
|
if (this.accessLevel === 'reseller_only') return true;
|
||||||
|
if (this.accessLevel === 'tier_specific') {
|
||||||
|
return this.tierAccess.includes(userTier);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
MarketingAsset.prototype.isExpired = function() {
|
||||||
|
return this.expiryDate && new Date() > this.expiryDate;
|
||||||
|
};
|
||||||
|
|
||||||
|
MarketingAsset.prototype.getDownloadUrl = function() {
|
||||||
|
return `/uploads/marketing/${this.filePath}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
MarketingAsset.prototype.getThumbnailUrl = function() {
|
||||||
|
if (this.thumbnailPath) {
|
||||||
|
return `/uploads/marketing/thumbnails/${this.thumbnailPath}`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Class methods
|
||||||
|
MarketingAsset.associate = function(models) {
|
||||||
|
MarketingAsset.belongsTo(models.User, {
|
||||||
|
foreignKey: 'createdBy',
|
||||||
|
as: 'creator'
|
||||||
|
});
|
||||||
|
|
||||||
|
MarketingAsset.belongsTo(models.User, {
|
||||||
|
foreignKey: 'updatedBy',
|
||||||
|
as: 'updater'
|
||||||
|
});
|
||||||
|
|
||||||
|
MarketingAsset.hasMany(models.AssetDownload, {
|
||||||
|
foreignKey: 'assetId',
|
||||||
|
as: 'downloads'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return MarketingAsset;
|
||||||
|
};
|
||||||
126
src/models/Order.js
Normal file
126
src/models/Order.js
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
module.exports = (sequelize, DataTypes) => {
|
||||||
|
const Order = sequelize.define('Order', {
|
||||||
|
id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true
|
||||||
|
},
|
||||||
|
orderNumber: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
unique: true
|
||||||
|
},
|
||||||
|
resellerId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
references: {
|
||||||
|
model: 'resellers',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
customerId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
references: {
|
||||||
|
model: 'customers',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
type: DataTypes.ENUM('pending', 'confirmed', 'processing', 'completed', 'cancelled', 'failed'),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'pending'
|
||||||
|
},
|
||||||
|
orderType: {
|
||||||
|
type: DataTypes.ENUM('new_service', 'upgrade', 'downgrade', 'renewal', 'addon'),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'new_service'
|
||||||
|
},
|
||||||
|
subtotal: {
|
||||||
|
type: DataTypes.DECIMAL(15, 2),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 0
|
||||||
|
},
|
||||||
|
taxAmount: {
|
||||||
|
type: DataTypes.DECIMAL(15, 2),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 0
|
||||||
|
},
|
||||||
|
discountAmount: {
|
||||||
|
type: DataTypes.DECIMAL(15, 2),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 0
|
||||||
|
},
|
||||||
|
totalAmount: {
|
||||||
|
type: DataTypes.DECIMAL(15, 2),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 0
|
||||||
|
},
|
||||||
|
currency: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'INR'
|
||||||
|
},
|
||||||
|
notes: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
defaultValue: {}
|
||||||
|
},
|
||||||
|
createdBy: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: true,
|
||||||
|
references: {
|
||||||
|
model: 'users',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
tableName: 'orders',
|
||||||
|
indexes: [
|
||||||
|
{
|
||||||
|
unique: true,
|
||||||
|
fields: ['order_number']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['reseller_id']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['customer_id']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['status']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['order_type']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Class methods
|
||||||
|
Order.associate = function(models) {
|
||||||
|
Order.belongsTo(models.Reseller, {
|
||||||
|
foreignKey: 'resellerId',
|
||||||
|
as: 'reseller'
|
||||||
|
});
|
||||||
|
|
||||||
|
Order.belongsTo(models.Customer, {
|
||||||
|
foreignKey: 'customerId',
|
||||||
|
as: 'customer'
|
||||||
|
});
|
||||||
|
|
||||||
|
Order.belongsTo(models.User, {
|
||||||
|
foreignKey: 'createdBy',
|
||||||
|
as: 'creator'
|
||||||
|
});
|
||||||
|
|
||||||
|
Order.hasMany(models.OrderItem, {
|
||||||
|
foreignKey: 'orderId',
|
||||||
|
as: 'items'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return Order;
|
||||||
|
};
|
||||||
75
src/models/OrderItem.js
Normal file
75
src/models/OrderItem.js
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
module.exports = (sequelize, DataTypes) => {
|
||||||
|
const OrderItem = sequelize.define('OrderItem', {
|
||||||
|
id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true
|
||||||
|
},
|
||||||
|
orderId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
references: {
|
||||||
|
model: 'orders',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
productId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
references: {
|
||||||
|
model: 'products',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
quantity: {
|
||||||
|
type: DataTypes.DECIMAL(15, 4),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 1
|
||||||
|
},
|
||||||
|
unitPrice: {
|
||||||
|
type: DataTypes.DECIMAL(15, 2),
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
lineTotal: {
|
||||||
|
type: DataTypes.DECIMAL(15, 2),
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
configuration: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
defaultValue: {}
|
||||||
|
},
|
||||||
|
specifications: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
defaultValue: {}
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
defaultValue: {}
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
tableName: 'order_items',
|
||||||
|
indexes: [
|
||||||
|
{
|
||||||
|
fields: ['order_id']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['product_id']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Class methods
|
||||||
|
OrderItem.associate = function(models) {
|
||||||
|
OrderItem.belongsTo(models.Order, {
|
||||||
|
foreignKey: 'orderId',
|
||||||
|
as: 'order'
|
||||||
|
});
|
||||||
|
|
||||||
|
OrderItem.belongsTo(models.Product, {
|
||||||
|
foreignKey: 'productId',
|
||||||
|
as: 'product'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return OrderItem;
|
||||||
|
};
|
||||||
188
src/models/Product.js
Normal file
188
src/models/Product.js
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
module.exports = (sequelize, DataTypes) => {
|
||||||
|
const Product = sequelize.define('Product', {
|
||||||
|
id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
validate: {
|
||||||
|
len: [2, 100]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
category: {
|
||||||
|
type: DataTypes.ENUM('compute', 'storage', 'networking', 'database', 'security', 'analytics', 'ai_ml', 'other'),
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
subcategory: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
sku: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
unique: true
|
||||||
|
},
|
||||||
|
basePrice: {
|
||||||
|
type: DataTypes.DECIMAL(10, 2),
|
||||||
|
allowNull: false,
|
||||||
|
validate: {
|
||||||
|
min: 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
currency: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'INR'
|
||||||
|
},
|
||||||
|
billingType: {
|
||||||
|
type: DataTypes.ENUM('one_time', 'recurring', 'usage_based', 'tiered'),
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
billingCycle: {
|
||||||
|
type: DataTypes.ENUM('hourly', 'daily', 'weekly', 'monthly', 'quarterly', 'yearly'),
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
unit: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: true // e.g., 'GB', 'vCPU', 'instance', 'request'
|
||||||
|
},
|
||||||
|
specifications: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
defaultValue: {}
|
||||||
|
},
|
||||||
|
features: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
defaultValue: []
|
||||||
|
},
|
||||||
|
tierPricing: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
defaultValue: {
|
||||||
|
bronze: { margin: 20 },
|
||||||
|
silver: { margin: 25 },
|
||||||
|
gold: { margin: 30 },
|
||||||
|
platinum: { margin: 35 },
|
||||||
|
diamond: { margin: 40 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
type: DataTypes.ENUM('active', 'inactive', 'deprecated', 'coming_soon'),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'active'
|
||||||
|
},
|
||||||
|
availability: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
defaultValue: {
|
||||||
|
regions: [],
|
||||||
|
zones: []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
minimumCommitment: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
tags: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
defaultValue: []
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
defaultValue: {}
|
||||||
|
},
|
||||||
|
createdBy: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: true,
|
||||||
|
references: {
|
||||||
|
model: 'users',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
updatedBy: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: true,
|
||||||
|
references: {
|
||||||
|
model: 'users',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
tableName: 'products',
|
||||||
|
indexes: [
|
||||||
|
{
|
||||||
|
unique: true,
|
||||||
|
fields: ['sku']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['category']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['status']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['billing_type']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['name']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Instance methods
|
||||||
|
Product.prototype.calculateResellerPrice = function(resellerTier, customMargin = null) {
|
||||||
|
let margin = customMargin;
|
||||||
|
|
||||||
|
if (!margin && this.tierPricing[resellerTier]) {
|
||||||
|
margin = this.tierPricing[resellerTier].margin;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!margin) {
|
||||||
|
margin = 20; // Default margin
|
||||||
|
}
|
||||||
|
|
||||||
|
const markupAmount = (this.basePrice * margin) / 100;
|
||||||
|
return parseFloat(this.basePrice) + markupAmount;
|
||||||
|
};
|
||||||
|
|
||||||
|
Product.prototype.getMarginForTier = function(tier) {
|
||||||
|
return this.tierPricing[tier]?.margin || 20;
|
||||||
|
};
|
||||||
|
|
||||||
|
Product.prototype.isAvailableInRegion = function(region) {
|
||||||
|
return this.availability.regions.length === 0 || this.availability.regions.includes(region);
|
||||||
|
};
|
||||||
|
|
||||||
|
Product.prototype.isActive = function() {
|
||||||
|
return this.status === 'active';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Class methods
|
||||||
|
Product.associate = function(models) {
|
||||||
|
Product.belongsTo(models.User, {
|
||||||
|
foreignKey: 'createdBy',
|
||||||
|
as: 'creator'
|
||||||
|
});
|
||||||
|
|
||||||
|
Product.belongsTo(models.User, {
|
||||||
|
foreignKey: 'updatedBy',
|
||||||
|
as: 'updater'
|
||||||
|
});
|
||||||
|
|
||||||
|
Product.hasMany(models.ResellerPricing, {
|
||||||
|
foreignKey: 'productId',
|
||||||
|
as: 'resellerPricing'
|
||||||
|
});
|
||||||
|
|
||||||
|
Product.hasMany(models.OrderItem, {
|
||||||
|
foreignKey: 'productId',
|
||||||
|
as: 'orderItems'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return Product;
|
||||||
|
};
|
||||||
246
src/models/Reseller.js
Normal file
246
src/models/Reseller.js
Normal file
@ -0,0 +1,246 @@
|
|||||||
|
module.exports = (sequelize, DataTypes) => {
|
||||||
|
const Reseller = sequelize.define('Reseller', {
|
||||||
|
id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true
|
||||||
|
},
|
||||||
|
companyName: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
validate: {
|
||||||
|
len: [2, 100]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
companyType: {
|
||||||
|
type: DataTypes.ENUM('individual', 'partnership', 'private_limited', 'public_limited', 'llp'),
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
registrationNumber: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: true,
|
||||||
|
unique: true
|
||||||
|
},
|
||||||
|
gstNumber: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: true,
|
||||||
|
validate: {
|
||||||
|
is: /^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z]{1}[1-9A-Z]{1}Z[0-9A-Z]{1}$/
|
||||||
|
}
|
||||||
|
},
|
||||||
|
panNumber: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: true,
|
||||||
|
validate: {
|
||||||
|
is: /^[A-Z]{5}[0-9]{4}[A-Z]{1}$/
|
||||||
|
}
|
||||||
|
},
|
||||||
|
address: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
allowNull: false,
|
||||||
|
validate: {
|
||||||
|
hasRequiredFields(value) {
|
||||||
|
if (!value.street || !value.city || !value.state || !value.country || !value.zipCode) {
|
||||||
|
throw new Error('Address must include street, city, state, country, and zipCode');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
contactEmail: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
validate: {
|
||||||
|
isEmail: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
contactPhone: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
validate: {
|
||||||
|
is: /^[\+]?[1-9][\d]{0,15}$/
|
||||||
|
}
|
||||||
|
},
|
||||||
|
website: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: true,
|
||||||
|
validate: {
|
||||||
|
isUrl: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tier: {
|
||||||
|
type: DataTypes.ENUM('bronze', 'silver', 'gold', 'platinum', 'diamond'),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'bronze'
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
type: DataTypes.ENUM('active', 'inactive', 'suspended', 'pending_approval', 'rejected'),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'pending_approval'
|
||||||
|
},
|
||||||
|
commissionRate: {
|
||||||
|
type: DataTypes.DECIMAL(5, 2),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 10.00,
|
||||||
|
validate: {
|
||||||
|
min: 0,
|
||||||
|
max: 100
|
||||||
|
}
|
||||||
|
},
|
||||||
|
marginSettings: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
defaultValue: {
|
||||||
|
defaultMargin: 20,
|
||||||
|
serviceMargins: {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
billingDetails: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
defaultValue: {
|
||||||
|
billingCycle: 'monthly',
|
||||||
|
paymentTerms: 30,
|
||||||
|
currency: 'INR'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
kycStatus: {
|
||||||
|
type: DataTypes.ENUM('pending', 'submitted', 'approved', 'rejected', 'expired'),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'pending'
|
||||||
|
},
|
||||||
|
kycDocuments: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
defaultValue: []
|
||||||
|
},
|
||||||
|
approvedBy: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: true,
|
||||||
|
references: {
|
||||||
|
model: 'users',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
approvedAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
rejectionReason: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
contractStartDate: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
contractEndDate: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
notes: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
defaultValue: {}
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
tableName: 'resellers',
|
||||||
|
indexes: [
|
||||||
|
{
|
||||||
|
unique: true,
|
||||||
|
fields: ['registration_number']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
unique: true,
|
||||||
|
fields: ['gst_number']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['tier']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['status']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['kyc_status']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['contact_email']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Instance methods
|
||||||
|
Reseller.prototype.calculateCommission = function(amount, serviceType = null) {
|
||||||
|
let rate = this.commissionRate;
|
||||||
|
|
||||||
|
if (serviceType && this.marginSettings.serviceMargins[serviceType]) {
|
||||||
|
rate = this.marginSettings.serviceMargins[serviceType];
|
||||||
|
}
|
||||||
|
|
||||||
|
return (amount * rate) / 100;
|
||||||
|
};
|
||||||
|
|
||||||
|
Reseller.prototype.isActive = function() {
|
||||||
|
return this.status === 'active' && this.kycStatus === 'approved';
|
||||||
|
};
|
||||||
|
|
||||||
|
Reseller.prototype.canUpgradeTier = function() {
|
||||||
|
const tierOrder = ['bronze', 'silver', 'gold', 'platinum', 'diamond'];
|
||||||
|
const currentIndex = tierOrder.indexOf(this.tier);
|
||||||
|
return currentIndex < tierOrder.length - 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
Reseller.prototype.getNextTier = function() {
|
||||||
|
const tierOrder = ['bronze', 'silver', 'gold', 'platinum', 'diamond'];
|
||||||
|
const currentIndex = tierOrder.indexOf(this.tier);
|
||||||
|
return currentIndex < tierOrder.length - 1 ? tierOrder[currentIndex + 1] : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
Reseller.prototype.updateKycStatus = async function(status, documents = null, rejectionReason = null) {
|
||||||
|
const updates = { kycStatus: status };
|
||||||
|
|
||||||
|
if (documents) {
|
||||||
|
updates.kycDocuments = documents;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'rejected' && rejectionReason) {
|
||||||
|
updates.rejectionReason = rejectionReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'approved') {
|
||||||
|
updates.approvedAt = new Date();
|
||||||
|
updates.status = 'active';
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.update(updates);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Class methods
|
||||||
|
Reseller.associate = function(models) {
|
||||||
|
Reseller.hasMany(models.User, {
|
||||||
|
foreignKey: 'resellerId',
|
||||||
|
as: 'users'
|
||||||
|
});
|
||||||
|
|
||||||
|
Reseller.belongsTo(models.User, {
|
||||||
|
foreignKey: 'approvedBy',
|
||||||
|
as: 'approver'
|
||||||
|
});
|
||||||
|
|
||||||
|
Reseller.hasMany(models.Customer, {
|
||||||
|
foreignKey: 'resellerId',
|
||||||
|
as: 'customers'
|
||||||
|
});
|
||||||
|
|
||||||
|
Reseller.hasMany(models.Order, {
|
||||||
|
foreignKey: 'resellerId',
|
||||||
|
as: 'orders'
|
||||||
|
});
|
||||||
|
|
||||||
|
Reseller.hasMany(models.Commission, {
|
||||||
|
foreignKey: 'resellerId',
|
||||||
|
as: 'commissions'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return Reseller;
|
||||||
|
};
|
||||||
176
src/models/ResellerPricing.js
Normal file
176
src/models/ResellerPricing.js
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
module.exports = (sequelize, DataTypes) => {
|
||||||
|
const ResellerPricing = sequelize.define('ResellerPricing', {
|
||||||
|
id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true
|
||||||
|
},
|
||||||
|
resellerId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
references: {
|
||||||
|
model: 'resellers',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
productId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
references: {
|
||||||
|
model: 'products',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
customMargin: {
|
||||||
|
type: DataTypes.DECIMAL(5, 2),
|
||||||
|
allowNull: true,
|
||||||
|
validate: {
|
||||||
|
min: 0,
|
||||||
|
max: 100
|
||||||
|
}
|
||||||
|
},
|
||||||
|
customPrice: {
|
||||||
|
type: DataTypes.DECIMAL(10, 2),
|
||||||
|
allowNull: true,
|
||||||
|
validate: {
|
||||||
|
min: 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
pricingType: {
|
||||||
|
type: DataTypes.ENUM('margin', 'fixed_price'),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'margin'
|
||||||
|
},
|
||||||
|
minimumQuantity: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: true,
|
||||||
|
defaultValue: 1
|
||||||
|
},
|
||||||
|
maximumQuantity: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
volumeDiscounts: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
defaultValue: []
|
||||||
|
},
|
||||||
|
isActive: {
|
||||||
|
type: DataTypes.BOOLEAN,
|
||||||
|
defaultValue: true
|
||||||
|
},
|
||||||
|
effectiveFrom: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: DataTypes.NOW
|
||||||
|
},
|
||||||
|
effectiveTo: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
notes: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
approvedBy: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: true,
|
||||||
|
references: {
|
||||||
|
model: 'users',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
approvedAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: true
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
tableName: 'reseller_pricing',
|
||||||
|
indexes: [
|
||||||
|
{
|
||||||
|
unique: true,
|
||||||
|
fields: ['reseller_id', 'product_id'],
|
||||||
|
where: {
|
||||||
|
is_active: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['reseller_id']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['product_id']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['is_active']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['effective_from']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['effective_to']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Instance methods
|
||||||
|
ResellerPricing.prototype.calculateFinalPrice = function(basePrice, quantity = 1) {
|
||||||
|
let finalPrice;
|
||||||
|
|
||||||
|
if (this.pricingType === 'fixed_price' && this.customPrice) {
|
||||||
|
finalPrice = this.customPrice;
|
||||||
|
} else {
|
||||||
|
const margin = this.customMargin || 20;
|
||||||
|
finalPrice = basePrice + (basePrice * margin / 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply volume discounts
|
||||||
|
if (this.volumeDiscounts && this.volumeDiscounts.length > 0) {
|
||||||
|
const applicableDiscount = this.volumeDiscounts
|
||||||
|
.filter(discount => quantity >= discount.minQuantity)
|
||||||
|
.sort((a, b) => b.minQuantity - a.minQuantity)[0];
|
||||||
|
|
||||||
|
if (applicableDiscount) {
|
||||||
|
finalPrice = finalPrice * (1 - applicableDiscount.discountPercent / 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseFloat(finalPrice.toFixed(2));
|
||||||
|
};
|
||||||
|
|
||||||
|
ResellerPricing.prototype.isEffective = function(date = new Date()) {
|
||||||
|
const checkDate = new Date(date);
|
||||||
|
const from = new Date(this.effectiveFrom);
|
||||||
|
const to = this.effectiveTo ? new Date(this.effectiveTo) : null;
|
||||||
|
|
||||||
|
return checkDate >= from && (!to || checkDate <= to);
|
||||||
|
};
|
||||||
|
|
||||||
|
ResellerPricing.prototype.getVolumeDiscount = function(quantity) {
|
||||||
|
if (!this.volumeDiscounts || this.volumeDiscounts.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.volumeDiscounts
|
||||||
|
.filter(discount => quantity >= discount.minQuantity)
|
||||||
|
.sort((a, b) => b.minQuantity - a.minQuantity)[0] || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Class methods
|
||||||
|
ResellerPricing.associate = function(models) {
|
||||||
|
ResellerPricing.belongsTo(models.Reseller, {
|
||||||
|
foreignKey: 'resellerId',
|
||||||
|
as: 'reseller'
|
||||||
|
});
|
||||||
|
|
||||||
|
ResellerPricing.belongsTo(models.Product, {
|
||||||
|
foreignKey: 'productId',
|
||||||
|
as: 'product'
|
||||||
|
});
|
||||||
|
|
||||||
|
ResellerPricing.belongsTo(models.User, {
|
||||||
|
foreignKey: 'approvedBy',
|
||||||
|
as: 'approver'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return ResellerPricing;
|
||||||
|
};
|
||||||
88
src/models/ServiceAlert.js
Normal file
88
src/models/ServiceAlert.js
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
module.exports = (sequelize, DataTypes) => {
|
||||||
|
const ServiceAlert = sequelize.define('ServiceAlert', {
|
||||||
|
id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true
|
||||||
|
},
|
||||||
|
serviceId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
references: {
|
||||||
|
model: 'customer_services',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
alertType: {
|
||||||
|
type: DataTypes.ENUM('usage_threshold', 'billing_threshold', 'service_down', 'maintenance', 'expiry_warning'),
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
severity: {
|
||||||
|
type: DataTypes.ENUM('low', 'medium', 'high', 'critical'),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'medium'
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
type: DataTypes.ENUM('active', 'acknowledged', 'resolved', 'dismissed'),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'active'
|
||||||
|
},
|
||||||
|
threshold: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
currentValue: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
acknowledgedAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
resolvedAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
defaultValue: {}
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
tableName: 'service_alerts',
|
||||||
|
indexes: [
|
||||||
|
{
|
||||||
|
fields: ['service_id']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['alert_type']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['severity']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['status']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['created_at']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Class methods
|
||||||
|
ServiceAlert.associate = function(models) {
|
||||||
|
ServiceAlert.belongsTo(models.CustomerService, {
|
||||||
|
foreignKey: 'serviceId',
|
||||||
|
as: 'service'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return ServiceAlert;
|
||||||
|
};
|
||||||
179
src/models/UsageRecord.js
Normal file
179
src/models/UsageRecord.js
Normal file
@ -0,0 +1,179 @@
|
|||||||
|
module.exports = (sequelize, DataTypes) => {
|
||||||
|
const UsageRecord = sequelize.define('UsageRecord', {
|
||||||
|
id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true
|
||||||
|
},
|
||||||
|
customerId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
references: {
|
||||||
|
model: 'customers',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
serviceId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
references: {
|
||||||
|
model: 'customer_services',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
productId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
references: {
|
||||||
|
model: 'products',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
usageDate: {
|
||||||
|
type: DataTypes.DATEONLY,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
usageHour: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: true,
|
||||||
|
validate: {
|
||||||
|
min: 0,
|
||||||
|
max: 23
|
||||||
|
}
|
||||||
|
},
|
||||||
|
metricType: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false // e.g., 'cpu_hours', 'storage_gb', 'bandwidth_gb', 'requests'
|
||||||
|
},
|
||||||
|
quantity: {
|
||||||
|
type: DataTypes.DECIMAL(15, 4),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 0
|
||||||
|
},
|
||||||
|
unit: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false // e.g., 'hours', 'GB', 'requests', 'instances'
|
||||||
|
},
|
||||||
|
unitPrice: {
|
||||||
|
type: DataTypes.DECIMAL(10, 4),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 0
|
||||||
|
},
|
||||||
|
totalCost: {
|
||||||
|
type: DataTypes.DECIMAL(15, 2),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 0
|
||||||
|
},
|
||||||
|
currency: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'INR'
|
||||||
|
},
|
||||||
|
region: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
zone: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
resourceId: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
tags: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
defaultValue: {}
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
defaultValue: {}
|
||||||
|
},
|
||||||
|
billingStatus: {
|
||||||
|
type: DataTypes.ENUM('pending', 'billed', 'disputed', 'refunded'),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'pending'
|
||||||
|
},
|
||||||
|
billedAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
invoiceId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: true,
|
||||||
|
references: {
|
||||||
|
model: 'invoices',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
tableName: 'usage_records',
|
||||||
|
indexes: [
|
||||||
|
{
|
||||||
|
fields: ['customer_id']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['service_id']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['product_id']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['usage_date']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['metric_type']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['billing_status']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['resource_id']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
unique: true,
|
||||||
|
fields: ['service_id', 'usage_date', 'usage_hour', 'metric_type']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Instance methods
|
||||||
|
UsageRecord.prototype.calculateCost = function() {
|
||||||
|
const quantity = parseFloat(this.quantity) || 0;
|
||||||
|
const unitPrice = parseFloat(this.unitPrice) || 0;
|
||||||
|
return parseFloat((quantity * unitPrice).toFixed(2));
|
||||||
|
};
|
||||||
|
|
||||||
|
UsageRecord.prototype.markAsBilled = async function(invoiceId) {
|
||||||
|
return this.update({
|
||||||
|
billingStatus: 'billed',
|
||||||
|
billedAt: new Date(),
|
||||||
|
invoiceId: invoiceId
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Class methods
|
||||||
|
UsageRecord.associate = function(models) {
|
||||||
|
UsageRecord.belongsTo(models.Customer, {
|
||||||
|
foreignKey: 'customerId',
|
||||||
|
as: 'customer'
|
||||||
|
});
|
||||||
|
|
||||||
|
UsageRecord.belongsTo(models.CustomerService, {
|
||||||
|
foreignKey: 'serviceId',
|
||||||
|
as: 'service'
|
||||||
|
});
|
||||||
|
|
||||||
|
UsageRecord.belongsTo(models.Product, {
|
||||||
|
foreignKey: 'productId',
|
||||||
|
as: 'product'
|
||||||
|
});
|
||||||
|
|
||||||
|
UsageRecord.belongsTo(models.Invoice, {
|
||||||
|
foreignKey: 'invoiceId',
|
||||||
|
as: 'invoice'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return UsageRecord;
|
||||||
|
};
|
||||||
260
src/models/User.js
Normal file
260
src/models/User.js
Normal file
@ -0,0 +1,260 @@
|
|||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
const speakeasy = require('speakeasy');
|
||||||
|
|
||||||
|
module.exports = (sequelize, DataTypes) => {
|
||||||
|
const User = sequelize.define('User', {
|
||||||
|
id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true
|
||||||
|
},
|
||||||
|
email: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
unique: true,
|
||||||
|
validate: {
|
||||||
|
isEmail: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
password: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
validate: {
|
||||||
|
len: [8, 128]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
firstName: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
validate: {
|
||||||
|
len: [2, 50]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
lastName: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
validate: {
|
||||||
|
len: [2, 50]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
phone: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: true,
|
||||||
|
validate: {
|
||||||
|
is: /^[\+]?[1-9][\d]{0,15}$/
|
||||||
|
}
|
||||||
|
},
|
||||||
|
role: {
|
||||||
|
type: DataTypes.ENUM('reseller_admin', 'sales_agent', 'support_agent', 'read_only'),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'read_only'
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
type: DataTypes.ENUM('active', 'inactive', 'suspended', 'pending_verification'),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'pending_verification'
|
||||||
|
},
|
||||||
|
emailVerified: {
|
||||||
|
type: DataTypes.BOOLEAN,
|
||||||
|
defaultValue: false
|
||||||
|
},
|
||||||
|
emailVerificationToken: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
emailVerificationExpires: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
passwordResetToken: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
passwordResetExpires: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
mfaEnabled: {
|
||||||
|
type: DataTypes.BOOLEAN,
|
||||||
|
defaultValue: false
|
||||||
|
},
|
||||||
|
mfaSecret: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
mfaBackupCodes: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
lastLogin: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
loginAttempts: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
defaultValue: 0
|
||||||
|
},
|
||||||
|
lockUntil: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
avatar: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
timezone: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
defaultValue: 'UTC'
|
||||||
|
},
|
||||||
|
language: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
defaultValue: 'en'
|
||||||
|
},
|
||||||
|
preferences: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
defaultValue: {}
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
tableName: 'users',
|
||||||
|
indexes: [
|
||||||
|
{
|
||||||
|
unique: true,
|
||||||
|
fields: ['email']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['role']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['status']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['email_verification_token']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['password_reset_token']
|
||||||
|
}
|
||||||
|
],
|
||||||
|
hooks: {
|
||||||
|
beforeCreate: async (user) => {
|
||||||
|
if (user.password) {
|
||||||
|
const salt = await bcrypt.genSalt(parseInt(process.env.BCRYPT_ROUNDS) || 12);
|
||||||
|
user.password = await bcrypt.hash(user.password, salt);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
beforeUpdate: async (user) => {
|
||||||
|
if (user.changed('password')) {
|
||||||
|
const salt = await bcrypt.genSalt(parseInt(process.env.BCRYPT_ROUNDS) || 12);
|
||||||
|
user.password = await bcrypt.hash(user.password, salt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Instance methods
|
||||||
|
User.prototype.validatePassword = async function(password) {
|
||||||
|
return await bcrypt.compare(password, this.password);
|
||||||
|
};
|
||||||
|
|
||||||
|
User.prototype.generateMfaSecret = function() {
|
||||||
|
const secret = speakeasy.generateSecret({
|
||||||
|
name: `Cloudtopiaa Reseller Portal (${this.email})`,
|
||||||
|
issuer: 'Cloudtopiaa'
|
||||||
|
});
|
||||||
|
this.mfaSecret = secret.base32;
|
||||||
|
return secret;
|
||||||
|
};
|
||||||
|
|
||||||
|
User.prototype.verifyMfaToken = function(token) {
|
||||||
|
if (!this.mfaSecret) return false;
|
||||||
|
|
||||||
|
return speakeasy.totp.verify({
|
||||||
|
secret: this.mfaSecret,
|
||||||
|
encoding: 'base32',
|
||||||
|
token: token,
|
||||||
|
window: 2
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
User.prototype.generateMfaBackupCodes = function() {
|
||||||
|
const codes = [];
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
codes.push(Math.random().toString(36).substring(2, 10).toUpperCase());
|
||||||
|
}
|
||||||
|
this.mfaBackupCodes = codes;
|
||||||
|
return codes;
|
||||||
|
};
|
||||||
|
|
||||||
|
User.prototype.verifyMfaBackupCode = function(code) {
|
||||||
|
if (!this.mfaBackupCodes || !Array.isArray(this.mfaBackupCodes)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = this.mfaBackupCodes.indexOf(code.toUpperCase());
|
||||||
|
if (index > -1) {
|
||||||
|
this.mfaBackupCodes.splice(index, 1);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
User.prototype.isLocked = function() {
|
||||||
|
return !!(this.lockUntil && this.lockUntil > Date.now());
|
||||||
|
};
|
||||||
|
|
||||||
|
User.prototype.incrementLoginAttempts = async function() {
|
||||||
|
// If we have a previous lock that has expired, restart at 1
|
||||||
|
if (this.lockUntil && this.lockUntil < Date.now()) {
|
||||||
|
return this.update({
|
||||||
|
loginAttempts: 1,
|
||||||
|
lockUntil: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates = { loginAttempts: this.loginAttempts + 1 };
|
||||||
|
|
||||||
|
// Lock account after 5 failed attempts for 2 hours
|
||||||
|
if (this.loginAttempts + 1 >= 5 && !this.isLocked()) {
|
||||||
|
updates.lockUntil = Date.now() + 2 * 60 * 60 * 1000; // 2 hours
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.update(updates);
|
||||||
|
};
|
||||||
|
|
||||||
|
User.prototype.resetLoginAttempts = async function() {
|
||||||
|
return this.update({
|
||||||
|
loginAttempts: 0,
|
||||||
|
lockUntil: null,
|
||||||
|
lastLogin: new Date()
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
User.prototype.toJSON = function() {
|
||||||
|
const values = Object.assign({}, this.get());
|
||||||
|
delete values.password;
|
||||||
|
delete values.mfaSecret;
|
||||||
|
delete values.mfaBackupCodes;
|
||||||
|
delete values.emailVerificationToken;
|
||||||
|
delete values.passwordResetToken;
|
||||||
|
return values;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Class methods
|
||||||
|
User.associate = function(models) {
|
||||||
|
User.belongsTo(models.Reseller, {
|
||||||
|
foreignKey: 'resellerId',
|
||||||
|
as: 'reseller'
|
||||||
|
});
|
||||||
|
|
||||||
|
User.hasMany(models.UserSession, {
|
||||||
|
foreignKey: 'userId',
|
||||||
|
as: 'sessions'
|
||||||
|
});
|
||||||
|
|
||||||
|
User.hasMany(models.AuditLog, {
|
||||||
|
foreignKey: 'userId',
|
||||||
|
as: 'auditLogs'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return User;
|
||||||
|
};
|
||||||
86
src/models/UserSession.js
Normal file
86
src/models/UserSession.js
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
module.exports = (sequelize, DataTypes) => {
|
||||||
|
const UserSession = sequelize.define('UserSession', {
|
||||||
|
id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true
|
||||||
|
},
|
||||||
|
userId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
references: {
|
||||||
|
model: 'users',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
refreshToken: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
unique: true
|
||||||
|
},
|
||||||
|
deviceInfo: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
defaultValue: {}
|
||||||
|
},
|
||||||
|
ipAddress: {
|
||||||
|
type: DataTypes.INET,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
userAgent: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
isActive: {
|
||||||
|
type: DataTypes.BOOLEAN,
|
||||||
|
defaultValue: true
|
||||||
|
},
|
||||||
|
expiresAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
lastUsedAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
defaultValue: DataTypes.NOW
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
tableName: 'user_sessions',
|
||||||
|
indexes: [
|
||||||
|
{
|
||||||
|
unique: true,
|
||||||
|
fields: ['refresh_token']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['user_id']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['is_active']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['expires_at']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Instance methods
|
||||||
|
UserSession.prototype.isExpired = function() {
|
||||||
|
return this.expiresAt < new Date();
|
||||||
|
};
|
||||||
|
|
||||||
|
UserSession.prototype.updateLastUsed = async function() {
|
||||||
|
return this.update({ lastUsedAt: new Date() });
|
||||||
|
};
|
||||||
|
|
||||||
|
UserSession.prototype.deactivate = async function() {
|
||||||
|
return this.update({ isActive: false });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Class methods
|
||||||
|
UserSession.associate = function(models) {
|
||||||
|
UserSession.belongsTo(models.User, {
|
||||||
|
foreignKey: 'userId',
|
||||||
|
as: 'user'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return UserSession;
|
||||||
|
};
|
||||||
207
src/models/Wallet.js
Normal file
207
src/models/Wallet.js
Normal file
@ -0,0 +1,207 @@
|
|||||||
|
module.exports = (sequelize, DataTypes) => {
|
||||||
|
const Wallet = sequelize.define('Wallet', {
|
||||||
|
id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true
|
||||||
|
},
|
||||||
|
resellerId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
unique: true,
|
||||||
|
references: {
|
||||||
|
model: 'resellers',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
balance: {
|
||||||
|
type: DataTypes.DECIMAL(15, 2),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 0.00,
|
||||||
|
validate: {
|
||||||
|
min: 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
currency: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'INR'
|
||||||
|
},
|
||||||
|
walletType: {
|
||||||
|
type: DataTypes.ENUM('prepaid', 'postpaid', 'hybrid'),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'postpaid'
|
||||||
|
},
|
||||||
|
creditLimit: {
|
||||||
|
type: DataTypes.DECIMAL(15, 2),
|
||||||
|
allowNull: true,
|
||||||
|
defaultValue: 0.00
|
||||||
|
},
|
||||||
|
availableCredit: {
|
||||||
|
type: DataTypes.DECIMAL(15, 2),
|
||||||
|
allowNull: true,
|
||||||
|
defaultValue: 0.00
|
||||||
|
},
|
||||||
|
reservedAmount: {
|
||||||
|
type: DataTypes.DECIMAL(15, 2),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 0.00
|
||||||
|
},
|
||||||
|
totalSpent: {
|
||||||
|
type: DataTypes.DECIMAL(15, 2),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 0.00
|
||||||
|
},
|
||||||
|
totalAdded: {
|
||||||
|
type: DataTypes.DECIMAL(15, 2),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 0.00
|
||||||
|
},
|
||||||
|
lowBalanceThreshold: {
|
||||||
|
type: DataTypes.DECIMAL(15, 2),
|
||||||
|
allowNull: true,
|
||||||
|
defaultValue: 1000.00
|
||||||
|
},
|
||||||
|
autoRecharge: {
|
||||||
|
type: DataTypes.BOOLEAN,
|
||||||
|
defaultValue: false
|
||||||
|
},
|
||||||
|
autoRechargeAmount: {
|
||||||
|
type: DataTypes.DECIMAL(15, 2),
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
autoRechargeThreshold: {
|
||||||
|
type: DataTypes.DECIMAL(15, 2),
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
type: DataTypes.ENUM('active', 'suspended', 'frozen'),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'active'
|
||||||
|
},
|
||||||
|
lastTransactionAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
defaultValue: {}
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
tableName: 'wallets',
|
||||||
|
indexes: [
|
||||||
|
{
|
||||||
|
unique: true,
|
||||||
|
fields: ['reseller_id']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['status']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['wallet_type']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['balance']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Instance methods
|
||||||
|
Wallet.prototype.getAvailableBalance = function() {
|
||||||
|
const balance = parseFloat(this.balance) || 0;
|
||||||
|
const reserved = parseFloat(this.reservedAmount) || 0;
|
||||||
|
const available = balance - reserved;
|
||||||
|
|
||||||
|
if (this.walletType === 'postpaid' || this.walletType === 'hybrid') {
|
||||||
|
const creditLimit = parseFloat(this.creditLimit) || 0;
|
||||||
|
return available + creditLimit;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.max(0, available);
|
||||||
|
};
|
||||||
|
|
||||||
|
Wallet.prototype.canDeduct = function(amount) {
|
||||||
|
const availableBalance = this.getAvailableBalance();
|
||||||
|
return availableBalance >= parseFloat(amount);
|
||||||
|
};
|
||||||
|
|
||||||
|
Wallet.prototype.addFunds = async function(amount, transactionId = null) {
|
||||||
|
const addAmount = parseFloat(amount);
|
||||||
|
const currentBalance = parseFloat(this.balance) || 0;
|
||||||
|
const currentTotalAdded = parseFloat(this.totalAdded) || 0;
|
||||||
|
|
||||||
|
return this.update({
|
||||||
|
balance: currentBalance + addAmount,
|
||||||
|
totalAdded: currentTotalAdded + addAmount,
|
||||||
|
lastTransactionAt: new Date()
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
Wallet.prototype.deductFunds = async function(amount, transactionId = null) {
|
||||||
|
const deductAmount = parseFloat(amount);
|
||||||
|
|
||||||
|
if (!this.canDeduct(deductAmount)) {
|
||||||
|
throw new Error('Insufficient funds');
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentBalance = parseFloat(this.balance) || 0;
|
||||||
|
const currentTotalSpent = parseFloat(this.totalSpent) || 0;
|
||||||
|
|
||||||
|
return this.update({
|
||||||
|
balance: currentBalance - deductAmount,
|
||||||
|
totalSpent: currentTotalSpent + deductAmount,
|
||||||
|
lastTransactionAt: new Date()
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
Wallet.prototype.reserveFunds = async function(amount) {
|
||||||
|
const reserveAmount = parseFloat(amount);
|
||||||
|
|
||||||
|
if (!this.canDeduct(reserveAmount)) {
|
||||||
|
throw new Error('Insufficient funds to reserve');
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentReserved = parseFloat(this.reservedAmount) || 0;
|
||||||
|
|
||||||
|
return this.update({
|
||||||
|
reservedAmount: currentReserved + reserveAmount
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
Wallet.prototype.releaseFunds = async function(amount) {
|
||||||
|
const releaseAmount = parseFloat(amount);
|
||||||
|
const currentReserved = parseFloat(this.reservedAmount) || 0;
|
||||||
|
|
||||||
|
return this.update({
|
||||||
|
reservedAmount: Math.max(0, currentReserved - releaseAmount)
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
Wallet.prototype.isLowBalance = function() {
|
||||||
|
const threshold = parseFloat(this.lowBalanceThreshold) || 0;
|
||||||
|
return parseFloat(this.balance) <= threshold;
|
||||||
|
};
|
||||||
|
|
||||||
|
Wallet.prototype.shouldAutoRecharge = function() {
|
||||||
|
if (!this.autoRecharge || !this.autoRechargeThreshold) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseFloat(this.balance) <= parseFloat(this.autoRechargeThreshold);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Class methods
|
||||||
|
Wallet.associate = function(models) {
|
||||||
|
Wallet.belongsTo(models.Reseller, {
|
||||||
|
foreignKey: 'resellerId',
|
||||||
|
as: 'reseller'
|
||||||
|
});
|
||||||
|
|
||||||
|
Wallet.hasMany(models.WalletTransaction, {
|
||||||
|
foreignKey: 'walletId',
|
||||||
|
as: 'transactions'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return Wallet;
|
||||||
|
};
|
||||||
242
src/models/WalletTransaction.js
Normal file
242
src/models/WalletTransaction.js
Normal file
@ -0,0 +1,242 @@
|
|||||||
|
module.exports = (sequelize, DataTypes) => {
|
||||||
|
const WalletTransaction = sequelize.define('WalletTransaction', {
|
||||||
|
id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true
|
||||||
|
},
|
||||||
|
walletId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
references: {
|
||||||
|
model: 'wallets',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
transactionId: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
unique: true
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
type: DataTypes.ENUM('credit', 'debit', 'reserve', 'release', 'refund', 'adjustment'),
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
category: {
|
||||||
|
type: DataTypes.ENUM('payment', 'service_charge', 'commission', 'refund', 'adjustment', 'penalty', 'bonus'),
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
amount: {
|
||||||
|
type: DataTypes.DECIMAL(15, 2),
|
||||||
|
allowNull: false,
|
||||||
|
validate: {
|
||||||
|
min: 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
currency: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'INR'
|
||||||
|
},
|
||||||
|
balanceBefore: {
|
||||||
|
type: DataTypes.DECIMAL(15, 2),
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
balanceAfter: {
|
||||||
|
type: DataTypes.DECIMAL(15, 2),
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
type: DataTypes.ENUM('pending', 'completed', 'failed', 'cancelled', 'reversed'),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'pending'
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
reference: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
defaultValue: {}
|
||||||
|
},
|
||||||
|
paymentGateway: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
gatewayTransactionId: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
gatewayResponse: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
defaultValue: {}
|
||||||
|
},
|
||||||
|
processedAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
processedBy: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: true,
|
||||||
|
references: {
|
||||||
|
model: 'users',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
reversalTransactionId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: true,
|
||||||
|
references: {
|
||||||
|
model: 'wallet_transactions',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
parentTransactionId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: true,
|
||||||
|
references: {
|
||||||
|
model: 'wallet_transactions',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
taxAmount: {
|
||||||
|
type: DataTypes.DECIMAL(10, 2),
|
||||||
|
allowNull: true,
|
||||||
|
defaultValue: 0.00
|
||||||
|
},
|
||||||
|
taxDetails: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
defaultValue: {}
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
defaultValue: {}
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
tableName: 'wallet_transactions',
|
||||||
|
indexes: [
|
||||||
|
{
|
||||||
|
unique: true,
|
||||||
|
fields: ['transaction_id']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['wallet_id']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['type']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['status']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['category']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['payment_gateway']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['gateway_transaction_id']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['processed_at']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['created_at']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Instance methods
|
||||||
|
WalletTransaction.prototype.markCompleted = async function() {
|
||||||
|
return this.update({
|
||||||
|
status: 'completed',
|
||||||
|
processedAt: new Date()
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
WalletTransaction.prototype.markFailed = async function(reason = null) {
|
||||||
|
const updates = {
|
||||||
|
status: 'failed',
|
||||||
|
processedAt: new Date()
|
||||||
|
};
|
||||||
|
|
||||||
|
if (reason) {
|
||||||
|
updates.metadata = {
|
||||||
|
...this.metadata,
|
||||||
|
failureReason: reason
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.update(updates);
|
||||||
|
};
|
||||||
|
|
||||||
|
WalletTransaction.prototype.reverse = async function(reason = null) {
|
||||||
|
const { WalletTransaction } = require('../models');
|
||||||
|
|
||||||
|
// Create reversal transaction
|
||||||
|
const reversalTransaction = await WalletTransaction.create({
|
||||||
|
walletId: this.walletId,
|
||||||
|
transactionId: `REV_${this.transactionId}_${Date.now()}`,
|
||||||
|
type: this.type === 'credit' ? 'debit' : 'credit',
|
||||||
|
category: 'adjustment',
|
||||||
|
amount: this.amount,
|
||||||
|
currency: this.currency,
|
||||||
|
balanceBefore: this.balanceAfter,
|
||||||
|
balanceAfter: this.balanceBefore,
|
||||||
|
status: 'completed',
|
||||||
|
description: `Reversal of transaction ${this.transactionId}${reason ? `: ${reason}` : ''}`,
|
||||||
|
parentTransactionId: this.id,
|
||||||
|
processedAt: new Date()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update original transaction
|
||||||
|
await this.update({
|
||||||
|
status: 'reversed',
|
||||||
|
reversalTransactionId: reversalTransaction.id
|
||||||
|
});
|
||||||
|
|
||||||
|
return reversalTransaction;
|
||||||
|
};
|
||||||
|
|
||||||
|
WalletTransaction.prototype.isReversible = function() {
|
||||||
|
return this.status === 'completed' && !this.reversalTransactionId;
|
||||||
|
};
|
||||||
|
|
||||||
|
WalletTransaction.prototype.calculateTax = function(taxRate = 18) {
|
||||||
|
if (this.category === 'service_charge' || this.category === 'commission') {
|
||||||
|
const taxAmount = (parseFloat(this.amount) * taxRate) / 100;
|
||||||
|
return parseFloat(taxAmount.toFixed(2));
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Class methods
|
||||||
|
WalletTransaction.associate = function(models) {
|
||||||
|
WalletTransaction.belongsTo(models.Wallet, {
|
||||||
|
foreignKey: 'walletId',
|
||||||
|
as: 'wallet'
|
||||||
|
});
|
||||||
|
|
||||||
|
WalletTransaction.belongsTo(models.User, {
|
||||||
|
foreignKey: 'processedBy',
|
||||||
|
as: 'processor'
|
||||||
|
});
|
||||||
|
|
||||||
|
WalletTransaction.belongsTo(WalletTransaction, {
|
||||||
|
foreignKey: 'reversalTransactionId',
|
||||||
|
as: 'reversalTransaction'
|
||||||
|
});
|
||||||
|
|
||||||
|
WalletTransaction.belongsTo(WalletTransaction, {
|
||||||
|
foreignKey: 'parentTransactionId',
|
||||||
|
as: 'parentTransaction'
|
||||||
|
});
|
||||||
|
|
||||||
|
WalletTransaction.hasOne(WalletTransaction, {
|
||||||
|
foreignKey: 'parentTransactionId',
|
||||||
|
as: 'childTransaction'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return WalletTransaction;
|
||||||
|
};
|
||||||
41
src/models/index.js
Normal file
41
src/models/index.js
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const Sequelize = require('sequelize');
|
||||||
|
const process = require('process');
|
||||||
|
const basename = path.basename(__filename);
|
||||||
|
const env = process.env.NODE_ENV || 'development';
|
||||||
|
const config = require(__dirname + '/../config/database.js')[env];
|
||||||
|
const db = {};
|
||||||
|
|
||||||
|
let sequelize;
|
||||||
|
if (config.use_env_variable) {
|
||||||
|
sequelize = new Sequelize(process.env[config.use_env_variable], config);
|
||||||
|
} else {
|
||||||
|
sequelize = new Sequelize(config.database, config.username, config.password, config);
|
||||||
|
}
|
||||||
|
|
||||||
|
fs
|
||||||
|
.readdirSync(__dirname)
|
||||||
|
.filter(file => {
|
||||||
|
return (
|
||||||
|
file.indexOf('.') !== 0 &&
|
||||||
|
file !== basename &&
|
||||||
|
file.slice(-3) === '.js' &&
|
||||||
|
file.indexOf('.test.js') === -1
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.forEach(file => {
|
||||||
|
const model = require(path.join(__dirname, file))(sequelize, Sequelize.DataTypes);
|
||||||
|
db[model.name] = model;
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.keys(db).forEach(modelName => {
|
||||||
|
if (db[modelName].associate) {
|
||||||
|
db[modelName].associate(db);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
db.sequelize = sequelize;
|
||||||
|
db.Sequelize = Sequelize;
|
||||||
|
|
||||||
|
module.exports = db;
|
||||||
60
src/routes/admin.js
Normal file
60
src/routes/admin.js
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const { body, validationResult } = require('express-validator');
|
||||||
|
const { authorize } = require('../middleware/auth');
|
||||||
|
const adminController = require('../controllers/adminController');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Validation middleware
|
||||||
|
const handleValidationErrors = (req, res, next) => {
|
||||||
|
const errors = validationResult(req);
|
||||||
|
if (!errors.isEmpty()) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Validation failed',
|
||||||
|
errors: errors.array()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Product validation
|
||||||
|
const productValidation = [
|
||||||
|
body('name')
|
||||||
|
.trim()
|
||||||
|
.isLength({ min: 2, max: 100 })
|
||||||
|
.withMessage('Product name must be 2-100 characters'),
|
||||||
|
body('category')
|
||||||
|
.isIn(['compute', 'storage', 'networking', 'database', 'security', 'analytics', 'ai_ml', 'other'])
|
||||||
|
.withMessage('Invalid product category'),
|
||||||
|
body('sku')
|
||||||
|
.trim()
|
||||||
|
.isLength({ min: 2, max: 50 })
|
||||||
|
.withMessage('SKU must be 2-50 characters'),
|
||||||
|
body('basePrice')
|
||||||
|
.isFloat({ min: 0 })
|
||||||
|
.withMessage('Base price must be a positive number'),
|
||||||
|
body('billingType')
|
||||||
|
.isIn(['one_time', 'recurring', 'usage_based', 'tiered'])
|
||||||
|
.withMessage('Invalid billing type'),
|
||||||
|
body('billingCycle')
|
||||||
|
.optional()
|
||||||
|
.isIn(['hourly', 'daily', 'weekly', 'monthly', 'quarterly', 'yearly'])
|
||||||
|
.withMessage('Invalid billing cycle')
|
||||||
|
];
|
||||||
|
|
||||||
|
// All admin routes require admin role
|
||||||
|
router.use(authorize('reseller_admin'));
|
||||||
|
|
||||||
|
// Product management
|
||||||
|
router.post('/products', productValidation, handleValidationErrors, adminController.createProduct);
|
||||||
|
router.put('/products/:productId', productValidation, handleValidationErrors, adminController.updateProduct);
|
||||||
|
router.get('/products', adminController.getAllProducts);
|
||||||
|
|
||||||
|
// System statistics
|
||||||
|
router.get('/stats', adminController.getSystemStats);
|
||||||
|
|
||||||
|
// Recent activity
|
||||||
|
router.get('/activity', adminController.getRecentActivity);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
112
src/routes/auth.js
Normal file
112
src/routes/auth.js
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const { body, validationResult } = require('express-validator');
|
||||||
|
const authController = require('../controllers/authController');
|
||||||
|
const { authenticateToken, validateRefreshToken } = require('../middleware/auth');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Validation middleware
|
||||||
|
const handleValidationErrors = (req, res, next) => {
|
||||||
|
const errors = validationResult(req);
|
||||||
|
if (!errors.isEmpty()) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Validation failed',
|
||||||
|
errors: errors.array()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Registration validation
|
||||||
|
const registerValidation = [
|
||||||
|
body('email')
|
||||||
|
.isEmail()
|
||||||
|
.normalizeEmail()
|
||||||
|
.withMessage('Valid email is required'),
|
||||||
|
body('password')
|
||||||
|
.isLength({ min: 8 })
|
||||||
|
.matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/)
|
||||||
|
.withMessage('Password must be at least 8 characters with uppercase, lowercase, number and special character'),
|
||||||
|
body('firstName')
|
||||||
|
.trim()
|
||||||
|
.isLength({ min: 2, max: 50 })
|
||||||
|
.withMessage('First name must be 2-50 characters'),
|
||||||
|
body('lastName')
|
||||||
|
.trim()
|
||||||
|
.isLength({ min: 2, max: 50 })
|
||||||
|
.withMessage('Last name must be 2-50 characters'),
|
||||||
|
body('phone')
|
||||||
|
.optional()
|
||||||
|
.matches(/^[\+]?[1-9][\d]{0,15}$/)
|
||||||
|
.withMessage('Invalid phone number format'),
|
||||||
|
body('role')
|
||||||
|
.optional()
|
||||||
|
.isIn(['reseller_admin', 'sales_agent', 'support_agent', 'read_only'])
|
||||||
|
.withMessage('Invalid role')
|
||||||
|
];
|
||||||
|
|
||||||
|
// Login validation
|
||||||
|
const loginValidation = [
|
||||||
|
body('email')
|
||||||
|
.isEmail()
|
||||||
|
.normalizeEmail()
|
||||||
|
.withMessage('Valid email is required'),
|
||||||
|
body('password')
|
||||||
|
.notEmpty()
|
||||||
|
.withMessage('Password is required'),
|
||||||
|
body('mfaToken')
|
||||||
|
.optional()
|
||||||
|
.isLength({ min: 6, max: 6 })
|
||||||
|
.isNumeric()
|
||||||
|
.withMessage('MFA token must be 6 digits')
|
||||||
|
];
|
||||||
|
|
||||||
|
// Password reset validation
|
||||||
|
const forgotPasswordValidation = [
|
||||||
|
body('email')
|
||||||
|
.isEmail()
|
||||||
|
.normalizeEmail()
|
||||||
|
.withMessage('Valid email is required')
|
||||||
|
];
|
||||||
|
|
||||||
|
const resetPasswordValidation = [
|
||||||
|
body('token')
|
||||||
|
.notEmpty()
|
||||||
|
.withMessage('Reset token is required'),
|
||||||
|
body('newPassword')
|
||||||
|
.isLength({ min: 8 })
|
||||||
|
.matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/)
|
||||||
|
.withMessage('Password must be at least 8 characters with uppercase, lowercase, number and special character')
|
||||||
|
];
|
||||||
|
|
||||||
|
// Email verification validation
|
||||||
|
const verifyEmailValidation = [
|
||||||
|
body('token')
|
||||||
|
.notEmpty()
|
||||||
|
.withMessage('Verification token is required')
|
||||||
|
];
|
||||||
|
|
||||||
|
// MFA validation
|
||||||
|
const mfaTokenValidation = [
|
||||||
|
body('token')
|
||||||
|
.isLength({ min: 6, max: 6 })
|
||||||
|
.isNumeric()
|
||||||
|
.withMessage('MFA token must be 6 digits')
|
||||||
|
];
|
||||||
|
|
||||||
|
// Routes
|
||||||
|
router.post('/register', registerValidation, handleValidationErrors, authController.register);
|
||||||
|
router.post('/login', loginValidation, handleValidationErrors, authController.login);
|
||||||
|
router.post('/refresh-token', validateRefreshToken, authController.refreshToken);
|
||||||
|
router.post('/logout', authController.logout);
|
||||||
|
router.post('/verify-email', verifyEmailValidation, handleValidationErrors, authController.verifyEmail);
|
||||||
|
router.post('/forgot-password', forgotPasswordValidation, handleValidationErrors, authController.forgotPassword);
|
||||||
|
router.post('/reset-password', resetPasswordValidation, handleValidationErrors, authController.resetPassword);
|
||||||
|
|
||||||
|
// MFA routes (protected)
|
||||||
|
router.post('/setup-mfa', authenticateToken, authController.setupMFA);
|
||||||
|
router.post('/verify-mfa', authenticateToken, mfaTokenValidation, handleValidationErrors, authController.verifyMFA);
|
||||||
|
router.post('/disable-mfa', authenticateToken, mfaTokenValidation, handleValidationErrors, authController.disableMFA);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
72
src/routes/billing.js
Normal file
72
src/routes/billing.js
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const { body, validationResult } = require('express-validator');
|
||||||
|
const { authorize } = require('../middleware/auth');
|
||||||
|
const billingController = require('../controllers/billingController');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Validation middleware
|
||||||
|
const handleValidationErrors = (req, res, next) => {
|
||||||
|
const errors = validationResult(req);
|
||||||
|
if (!errors.isEmpty()) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Validation failed',
|
||||||
|
errors: errors.array()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add funds validation
|
||||||
|
const addFundsValidation = [
|
||||||
|
body('amount')
|
||||||
|
.isFloat({ min: 1 })
|
||||||
|
.withMessage('Amount must be greater than 0'),
|
||||||
|
body('paymentMethod')
|
||||||
|
.optional()
|
||||||
|
.isString()
|
||||||
|
.withMessage('Payment method must be a string'),
|
||||||
|
body('paymentReference')
|
||||||
|
.optional()
|
||||||
|
.isString()
|
||||||
|
.withMessage('Payment reference must be a string'),
|
||||||
|
body('description')
|
||||||
|
.optional()
|
||||||
|
.isString()
|
||||||
|
.withMessage('Description must be a string')
|
||||||
|
];
|
||||||
|
|
||||||
|
// Mark invoice paid validation
|
||||||
|
const markInvoicePaidValidation = [
|
||||||
|
body('amount')
|
||||||
|
.isFloat({ min: 0 })
|
||||||
|
.withMessage('Amount must be a positive number'),
|
||||||
|
body('paymentMethod')
|
||||||
|
.notEmpty()
|
||||||
|
.withMessage('Payment method is required'),
|
||||||
|
body('paymentReference')
|
||||||
|
.optional()
|
||||||
|
.isString()
|
||||||
|
.withMessage('Payment reference must be a string'),
|
||||||
|
body('notes')
|
||||||
|
.optional()
|
||||||
|
.isString()
|
||||||
|
.withMessage('Notes must be a string')
|
||||||
|
];
|
||||||
|
|
||||||
|
// Wallet routes
|
||||||
|
router.get('/wallet', billingController.getWallet);
|
||||||
|
router.post('/wallet/add-funds', addFundsValidation, handleValidationErrors, billingController.addFunds);
|
||||||
|
router.get('/wallet/transactions', billingController.getTransactions);
|
||||||
|
|
||||||
|
// Invoice routes
|
||||||
|
router.get('/invoices', billingController.getInvoices);
|
||||||
|
router.get('/invoices/:invoiceId', billingController.getInvoice);
|
||||||
|
router.put('/invoices/:invoiceId/paid', markInvoicePaidValidation, handleValidationErrors, billingController.markInvoicePaid);
|
||||||
|
router.get('/invoices/:invoiceId/download', billingController.downloadInvoice);
|
||||||
|
|
||||||
|
// Billing summary
|
||||||
|
router.get('/summary', billingController.getBillingSummary);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
129
src/routes/customers.js
Normal file
129
src/routes/customers.js
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const { body, validationResult } = require('express-validator');
|
||||||
|
const { authorize } = require('../middleware/auth');
|
||||||
|
const customerController = require('../controllers/customerController');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Validation middleware
|
||||||
|
const handleValidationErrors = (req, res, next) => {
|
||||||
|
const errors = validationResult(req);
|
||||||
|
if (!errors.isEmpty()) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Validation failed',
|
||||||
|
errors: errors.array()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Customer validation
|
||||||
|
const customerValidation = [
|
||||||
|
body('companyName')
|
||||||
|
.trim()
|
||||||
|
.isLength({ min: 2, max: 100 })
|
||||||
|
.withMessage('Company name must be 2-100 characters'),
|
||||||
|
body('contactPerson')
|
||||||
|
.trim()
|
||||||
|
.isLength({ min: 2, max: 100 })
|
||||||
|
.withMessage('Contact person must be 2-100 characters'),
|
||||||
|
body('email')
|
||||||
|
.isEmail()
|
||||||
|
.normalizeEmail()
|
||||||
|
.withMessage('Valid email is required'),
|
||||||
|
body('phone')
|
||||||
|
.optional()
|
||||||
|
.matches(/^[\+]?[1-9][\d]{0,15}$/)
|
||||||
|
.withMessage('Invalid phone number format'),
|
||||||
|
body('address')
|
||||||
|
.isObject()
|
||||||
|
.withMessage('Address must be an object'),
|
||||||
|
body('address.street')
|
||||||
|
.notEmpty()
|
||||||
|
.withMessage('Street address is required'),
|
||||||
|
body('address.city')
|
||||||
|
.notEmpty()
|
||||||
|
.withMessage('City is required'),
|
||||||
|
body('address.state')
|
||||||
|
.notEmpty()
|
||||||
|
.withMessage('State is required'),
|
||||||
|
body('address.country')
|
||||||
|
.notEmpty()
|
||||||
|
.withMessage('Country is required'),
|
||||||
|
body('address.zipCode')
|
||||||
|
.notEmpty()
|
||||||
|
.withMessage('Zip code is required'),
|
||||||
|
body('companySize')
|
||||||
|
.optional()
|
||||||
|
.isIn(['startup', 'small', 'medium', 'large', 'enterprise'])
|
||||||
|
.withMessage('Invalid company size'),
|
||||||
|
body('paymentMethod')
|
||||||
|
.optional()
|
||||||
|
.isIn(['credit_card', 'bank_transfer', 'invoice', 'prepaid'])
|
||||||
|
.withMessage('Invalid payment method'),
|
||||||
|
body('creditLimit')
|
||||||
|
.optional()
|
||||||
|
.isFloat({ min: 0 })
|
||||||
|
.withMessage('Credit limit must be a positive number'),
|
||||||
|
body('gstNumber')
|
||||||
|
.optional()
|
||||||
|
.matches(/^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z]{1}[1-9A-Z]{1}Z[0-9A-Z]{1}$/)
|
||||||
|
.withMessage('Invalid GST number format')
|
||||||
|
];
|
||||||
|
|
||||||
|
// Service assignment validation
|
||||||
|
const serviceAssignmentValidation = [
|
||||||
|
body('productId')
|
||||||
|
.isUUID()
|
||||||
|
.withMessage('Valid product ID is required'),
|
||||||
|
body('serviceName')
|
||||||
|
.trim()
|
||||||
|
.isLength({ min: 2, max: 100 })
|
||||||
|
.withMessage('Service name must be 2-100 characters'),
|
||||||
|
body('configuration')
|
||||||
|
.optional()
|
||||||
|
.isObject()
|
||||||
|
.withMessage('Configuration must be an object'),
|
||||||
|
body('specifications')
|
||||||
|
.optional()
|
||||||
|
.isObject()
|
||||||
|
.withMessage('Specifications must be an object'),
|
||||||
|
body('pricing')
|
||||||
|
.isObject()
|
||||||
|
.withMessage('Pricing information is required'),
|
||||||
|
body('pricing.basePrice')
|
||||||
|
.isFloat({ min: 0 })
|
||||||
|
.withMessage('Base price must be a positive number'),
|
||||||
|
body('pricing.finalPrice')
|
||||||
|
.isFloat({ min: 0 })
|
||||||
|
.withMessage('Final price must be a positive number'),
|
||||||
|
body('billingCycle')
|
||||||
|
.optional()
|
||||||
|
.isIn(['hourly', 'daily', 'weekly', 'monthly', 'quarterly', 'yearly'])
|
||||||
|
.withMessage('Invalid billing cycle'),
|
||||||
|
body('contractPeriod')
|
||||||
|
.optional()
|
||||||
|
.isInt({ min: 1 })
|
||||||
|
.withMessage('Contract period must be a positive integer (months)')
|
||||||
|
];
|
||||||
|
|
||||||
|
// Get all customers
|
||||||
|
router.get('/', customerController.getCustomers);
|
||||||
|
|
||||||
|
// Get customer details
|
||||||
|
router.get('/:customerId', customerController.getCustomer);
|
||||||
|
|
||||||
|
// Create new customer
|
||||||
|
router.post('/', customerValidation, handleValidationErrors, customerController.createCustomer);
|
||||||
|
|
||||||
|
// Update customer
|
||||||
|
router.put('/:customerId', customerValidation, handleValidationErrors, customerController.updateCustomer);
|
||||||
|
|
||||||
|
// Get customer usage details
|
||||||
|
router.get('/:customerId/usage', customerController.getCustomerUsage);
|
||||||
|
|
||||||
|
// Assign service to customer
|
||||||
|
router.post('/:customerId/services', serviceAssignmentValidation, handleValidationErrors, customerController.assignService);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
75
src/routes/dashboard.js
Normal file
75
src/routes/dashboard.js
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const { query, validationResult } = require('express-validator');
|
||||||
|
const dashboardController = require('../controllers/dashboardController');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Validation middleware
|
||||||
|
const handleValidationErrors = (req, res, next) => {
|
||||||
|
const errors = validationResult(req);
|
||||||
|
if (!errors.isEmpty()) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Validation failed',
|
||||||
|
errors: errors.array()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Chart data validation
|
||||||
|
const chartValidation = [
|
||||||
|
query('period')
|
||||||
|
.optional()
|
||||||
|
.isIn(['day', 'week', 'month', 'quarter'])
|
||||||
|
.withMessage('Period must be day, week, month, or quarter'),
|
||||||
|
query('range')
|
||||||
|
.optional()
|
||||||
|
.isInt({ min: 1, max: 24 })
|
||||||
|
.withMessage('Range must be between 1 and 24')
|
||||||
|
];
|
||||||
|
|
||||||
|
// Export validation
|
||||||
|
const exportValidation = [
|
||||||
|
query('format')
|
||||||
|
.optional()
|
||||||
|
.isIn(['json', 'csv'])
|
||||||
|
.withMessage('Format must be json or csv'),
|
||||||
|
query('period')
|
||||||
|
.optional()
|
||||||
|
.isIn(['day', 'week', 'month', 'quarter'])
|
||||||
|
.withMessage('Period must be day, week, month, or quarter')
|
||||||
|
];
|
||||||
|
|
||||||
|
// Dashboard overview
|
||||||
|
router.get('/overview', dashboardController.getDashboardOverview);
|
||||||
|
|
||||||
|
// Revenue chart data
|
||||||
|
router.get('/revenue-chart', chartValidation, handleValidationErrors, dashboardController.getRevenueChart);
|
||||||
|
|
||||||
|
// Service distribution chart
|
||||||
|
router.get('/service-distribution', dashboardController.getServiceDistribution);
|
||||||
|
|
||||||
|
// Recent customers
|
||||||
|
router.get('/recent-customers', [
|
||||||
|
query('limit')
|
||||||
|
.optional()
|
||||||
|
.isInt({ min: 1, max: 20 })
|
||||||
|
.withMessage('Limit must be between 1 and 20')
|
||||||
|
], handleValidationErrors, dashboardController.getRecentCustomers);
|
||||||
|
|
||||||
|
// Recent transactions
|
||||||
|
router.get('/recent-transactions', [
|
||||||
|
query('limit')
|
||||||
|
.optional()
|
||||||
|
.isInt({ min: 1, max: 20 })
|
||||||
|
.withMessage('Limit must be between 1 and 20')
|
||||||
|
], handleValidationErrors, dashboardController.getRecentTransactions);
|
||||||
|
|
||||||
|
// Usage trends
|
||||||
|
router.get('/usage-trends', chartValidation, handleValidationErrors, dashboardController.getUsageTrends);
|
||||||
|
|
||||||
|
// Export dashboard data
|
||||||
|
router.get('/export', exportValidation, handleValidationErrors, dashboardController.exportDashboardData);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
127
src/routes/legal.js
Normal file
127
src/routes/legal.js
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const { body, param, query, validationResult } = require('express-validator');
|
||||||
|
const { authorize } = require('../middleware/auth');
|
||||||
|
const { uploadLegal } = require('../middleware/upload');
|
||||||
|
const legalController = require('../controllers/legalController');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Validation middleware
|
||||||
|
const handleValidationErrors = (req, res, next) => {
|
||||||
|
const errors = validationResult(req);
|
||||||
|
if (!errors.isEmpty()) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Validation failed',
|
||||||
|
errors: errors.array()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Document validation
|
||||||
|
const documentValidation = [
|
||||||
|
body('title')
|
||||||
|
.trim()
|
||||||
|
.isLength({ min: 2, max: 200 })
|
||||||
|
.withMessage('Title must be 2-200 characters'),
|
||||||
|
body('type')
|
||||||
|
.isIn(['terms_of_service', 'privacy_policy', 'sla', 'compliance', 'agreement', 'other'])
|
||||||
|
.withMessage('Invalid document type'),
|
||||||
|
body('version')
|
||||||
|
.trim()
|
||||||
|
.isLength({ min: 1, max: 20 })
|
||||||
|
.withMessage('Version must be 1-20 characters'),
|
||||||
|
body('content')
|
||||||
|
.optional()
|
||||||
|
.trim()
|
||||||
|
.notEmpty()
|
||||||
|
.withMessage('Content cannot be empty'),
|
||||||
|
body('requiresAcceptance')
|
||||||
|
.optional()
|
||||||
|
.isBoolean()
|
||||||
|
.withMessage('Requires acceptance must be boolean'),
|
||||||
|
body('acceptanceType')
|
||||||
|
.optional()
|
||||||
|
.isIn(['click_through', 'signature', 'upload', 'none'])
|
||||||
|
.withMessage('Invalid acceptance type'),
|
||||||
|
body('effectiveDate')
|
||||||
|
.optional()
|
||||||
|
.isISO8601()
|
||||||
|
.withMessage('Effective date must be valid ISO 8601 date'),
|
||||||
|
body('expiryDate')
|
||||||
|
.optional()
|
||||||
|
.isISO8601()
|
||||||
|
.withMessage('Expiry date must be valid ISO 8601 date'),
|
||||||
|
body('category')
|
||||||
|
.optional()
|
||||||
|
.trim()
|
||||||
|
.isLength({ max: 50 })
|
||||||
|
.withMessage('Category must be less than 50 characters'),
|
||||||
|
body('tags')
|
||||||
|
.optional()
|
||||||
|
.isArray()
|
||||||
|
.withMessage('Tags must be an array')
|
||||||
|
];
|
||||||
|
|
||||||
|
// Acceptance validation
|
||||||
|
const acceptanceValidation = [
|
||||||
|
body('acceptanceMethod')
|
||||||
|
.optional()
|
||||||
|
.isIn(['click_through', 'signature', 'upload', 'email', 'physical'])
|
||||||
|
.withMessage('Invalid acceptance method'),
|
||||||
|
body('signatureData')
|
||||||
|
.optional()
|
||||||
|
.isObject()
|
||||||
|
.withMessage('Signature data must be an object')
|
||||||
|
];
|
||||||
|
|
||||||
|
// UUID parameter validation
|
||||||
|
const uuidValidation = [
|
||||||
|
param('documentId')
|
||||||
|
.isUUID()
|
||||||
|
.withMessage('Document ID must be a valid UUID')
|
||||||
|
];
|
||||||
|
|
||||||
|
// Query validation
|
||||||
|
const queryValidation = [
|
||||||
|
query('type')
|
||||||
|
.optional()
|
||||||
|
.isIn(['terms_of_service', 'privacy_policy', 'sla', 'compliance', 'agreement', 'other'])
|
||||||
|
.withMessage('Invalid document type'),
|
||||||
|
query('status')
|
||||||
|
.optional()
|
||||||
|
.isIn(['all', 'draft', 'active', 'archived', 'superseded'])
|
||||||
|
.withMessage('Invalid status')
|
||||||
|
];
|
||||||
|
|
||||||
|
// Public routes (no authentication required)
|
||||||
|
router.get('/', queryValidation, handleValidationErrors, legalController.getLegalDocuments);
|
||||||
|
router.get('/:documentId', uuidValidation, handleValidationErrors, legalController.getDocumentDetails);
|
||||||
|
router.get('/:documentId/download', uuidValidation, handleValidationErrors, legalController.downloadDocument);
|
||||||
|
|
||||||
|
// Protected routes (authentication required)
|
||||||
|
router.use(authorize('reseller'));
|
||||||
|
|
||||||
|
// Document acceptance
|
||||||
|
router.post('/:documentId/accept', [
|
||||||
|
...uuidValidation,
|
||||||
|
...acceptanceValidation
|
||||||
|
], handleValidationErrors, legalController.acceptDocument);
|
||||||
|
|
||||||
|
// Compliance document upload
|
||||||
|
router.post('/:documentId/upload', [
|
||||||
|
...uuidValidation,
|
||||||
|
uploadLegal.single('complianceDocument')
|
||||||
|
], handleValidationErrors, legalController.uploadComplianceDocument);
|
||||||
|
|
||||||
|
// User acceptances
|
||||||
|
router.get('/user/acceptances', legalController.getUserAcceptances);
|
||||||
|
|
||||||
|
// Admin routes (admin only)
|
||||||
|
router.post('/admin/documents', [
|
||||||
|
authorize('reseller_admin'),
|
||||||
|
...documentValidation
|
||||||
|
], handleValidationErrors, legalController.createLegalDocument);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
126
src/routes/marketing.js
Normal file
126
src/routes/marketing.js
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const { body, param, query, validationResult } = require('express-validator');
|
||||||
|
const { authorize } = require('../middleware/auth');
|
||||||
|
const { uploadMarketing } = require('../middleware/upload');
|
||||||
|
const marketingController = require('../controllers/marketingController');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Validation middleware
|
||||||
|
const handleValidationErrors = (req, res, next) => {
|
||||||
|
const errors = validationResult(req);
|
||||||
|
if (!errors.isEmpty()) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Validation failed',
|
||||||
|
errors: errors.array()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Asset validation
|
||||||
|
const assetValidation = [
|
||||||
|
body('title')
|
||||||
|
.trim()
|
||||||
|
.isLength({ min: 2, max: 200 })
|
||||||
|
.withMessage('Title must be 2-200 characters'),
|
||||||
|
body('type')
|
||||||
|
.isIn(['logo', 'brochure', 'presentation', 'video', 'image', 'document', 'template', 'other'])
|
||||||
|
.withMessage('Invalid asset type'),
|
||||||
|
body('category')
|
||||||
|
.isIn(['sales_collateral', 'pitch_deck', 'email_template', 'brand_assets', 'product_sheets', 'case_studies', 'other'])
|
||||||
|
.withMessage('Invalid asset category'),
|
||||||
|
body('description')
|
||||||
|
.optional()
|
||||||
|
.trim()
|
||||||
|
.isLength({ max: 1000 })
|
||||||
|
.withMessage('Description must be less than 1000 characters'),
|
||||||
|
body('accessLevel')
|
||||||
|
.optional()
|
||||||
|
.isIn(['public', 'reseller_only', 'tier_specific', 'admin_only'])
|
||||||
|
.withMessage('Invalid access level'),
|
||||||
|
body('tierAccess')
|
||||||
|
.optional()
|
||||||
|
.isArray()
|
||||||
|
.withMessage('Tier access must be an array'),
|
||||||
|
body('isEditable')
|
||||||
|
.optional()
|
||||||
|
.isBoolean()
|
||||||
|
.withMessage('Is editable must be boolean'),
|
||||||
|
body('tags')
|
||||||
|
.optional()
|
||||||
|
.isArray()
|
||||||
|
.withMessage('Tags must be an array'),
|
||||||
|
body('expiryDate')
|
||||||
|
.optional()
|
||||||
|
.isISO8601()
|
||||||
|
.withMessage('Expiry date must be valid ISO 8601 date')
|
||||||
|
];
|
||||||
|
|
||||||
|
// Download validation
|
||||||
|
const downloadValidation = [
|
||||||
|
body('purpose')
|
||||||
|
.optional()
|
||||||
|
.trim()
|
||||||
|
.isLength({ max: 255 })
|
||||||
|
.withMessage('Purpose must be less than 255 characters')
|
||||||
|
];
|
||||||
|
|
||||||
|
// UUID parameter validation
|
||||||
|
const uuidValidation = [
|
||||||
|
param('assetId')
|
||||||
|
.isUUID()
|
||||||
|
.withMessage('Asset ID must be a valid UUID')
|
||||||
|
];
|
||||||
|
|
||||||
|
// Query validation
|
||||||
|
const queryValidation = [
|
||||||
|
query('page')
|
||||||
|
.optional()
|
||||||
|
.isInt({ min: 1 })
|
||||||
|
.withMessage('Page must be a positive integer'),
|
||||||
|
query('limit')
|
||||||
|
.optional()
|
||||||
|
.isInt({ min: 1, max: 100 })
|
||||||
|
.withMessage('Limit must be between 1 and 100'),
|
||||||
|
query('type')
|
||||||
|
.optional()
|
||||||
|
.isIn(['logo', 'brochure', 'presentation', 'video', 'image', 'document', 'template', 'other'])
|
||||||
|
.withMessage('Invalid asset type'),
|
||||||
|
query('category')
|
||||||
|
.optional()
|
||||||
|
.isIn(['sales_collateral', 'pitch_deck', 'email_template', 'brand_assets', 'product_sheets', 'case_studies', 'other'])
|
||||||
|
.withMessage('Invalid asset category'),
|
||||||
|
query('search')
|
||||||
|
.optional()
|
||||||
|
.trim()
|
||||||
|
.isLength({ min: 2 })
|
||||||
|
.withMessage('Search query must be at least 2 characters')
|
||||||
|
];
|
||||||
|
|
||||||
|
// Public routes (no authentication required for some assets)
|
||||||
|
router.get('/', queryValidation, handleValidationErrors, marketingController.getMarketingAssets);
|
||||||
|
router.get('/categories', marketingController.getAssetCategories);
|
||||||
|
|
||||||
|
// Protected routes (authentication required)
|
||||||
|
router.use(authorize('reseller'));
|
||||||
|
|
||||||
|
// Asset management
|
||||||
|
router.get('/:assetId', uuidValidation, handleValidationErrors, marketingController.getAssetDetails);
|
||||||
|
router.post('/:assetId/download', [
|
||||||
|
...uuidValidation,
|
||||||
|
...downloadValidation
|
||||||
|
], handleValidationErrors, marketingController.downloadAsset);
|
||||||
|
|
||||||
|
// Download history
|
||||||
|
router.get('/user/downloads', marketingController.getDownloadHistory);
|
||||||
|
|
||||||
|
// Admin routes (admin only)
|
||||||
|
router.post('/admin/assets', [
|
||||||
|
authorize('reseller_admin'),
|
||||||
|
uploadMarketing.single('assetFile'),
|
||||||
|
...assetValidation
|
||||||
|
], handleValidationErrors, marketingController.createMarketingAsset);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
132
src/routes/orders.js
Normal file
132
src/routes/orders.js
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const { body, param, query, validationResult } = require('express-validator');
|
||||||
|
const { authorize } = require('../middleware/auth');
|
||||||
|
const ordersController = require('../controllers/ordersController');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Validation middleware
|
||||||
|
const handleValidationErrors = (req, res, next) => {
|
||||||
|
const errors = validationResult(req);
|
||||||
|
if (!errors.isEmpty()) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Validation failed',
|
||||||
|
errors: errors.array()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Order creation validation
|
||||||
|
const orderValidation = [
|
||||||
|
body('customerId')
|
||||||
|
.isUUID()
|
||||||
|
.withMessage('Customer ID must be a valid UUID'),
|
||||||
|
body('items')
|
||||||
|
.isArray({ min: 1 })
|
||||||
|
.withMessage('Items must be a non-empty array'),
|
||||||
|
body('items.*.productId')
|
||||||
|
.isUUID()
|
||||||
|
.withMessage('Product ID must be a valid UUID'),
|
||||||
|
body('items.*.quantity')
|
||||||
|
.isInt({ min: 1 })
|
||||||
|
.withMessage('Quantity must be a positive integer'),
|
||||||
|
body('items.*.customPrice')
|
||||||
|
.optional()
|
||||||
|
.isFloat({ min: 0 })
|
||||||
|
.withMessage('Custom price must be a positive number'),
|
||||||
|
body('items.*.configuration')
|
||||||
|
.optional()
|
||||||
|
.isObject()
|
||||||
|
.withMessage('Configuration must be an object'),
|
||||||
|
body('items.*.specifications')
|
||||||
|
.optional()
|
||||||
|
.isObject()
|
||||||
|
.withMessage('Specifications must be an object'),
|
||||||
|
body('type')
|
||||||
|
.optional()
|
||||||
|
.isIn(['new_service', 'upgrade', 'downgrade', 'renewal', 'addon'])
|
||||||
|
.withMessage('Invalid order type'),
|
||||||
|
body('notes')
|
||||||
|
.optional()
|
||||||
|
.trim()
|
||||||
|
.isLength({ max: 1000 })
|
||||||
|
.withMessage('Notes must be less than 1000 characters')
|
||||||
|
];
|
||||||
|
|
||||||
|
// Status update validation
|
||||||
|
const statusUpdateValidation = [
|
||||||
|
body('status')
|
||||||
|
.isIn(['draft', 'pending', 'confirmed', 'processing', 'completed', 'cancelled', 'refunded'])
|
||||||
|
.withMessage('Invalid order status'),
|
||||||
|
body('notes')
|
||||||
|
.optional()
|
||||||
|
.trim()
|
||||||
|
.isLength({ max: 1000 })
|
||||||
|
.withMessage('Notes must be less than 1000 characters')
|
||||||
|
];
|
||||||
|
|
||||||
|
// Cancel order validation
|
||||||
|
const cancelValidation = [
|
||||||
|
body('reason')
|
||||||
|
.trim()
|
||||||
|
.isLength({ min: 5, max: 500 })
|
||||||
|
.withMessage('Cancellation reason must be 5-500 characters')
|
||||||
|
];
|
||||||
|
|
||||||
|
// UUID parameter validation
|
||||||
|
const uuidValidation = [
|
||||||
|
param('orderId')
|
||||||
|
.isUUID()
|
||||||
|
.withMessage('Order ID must be a valid UUID')
|
||||||
|
];
|
||||||
|
|
||||||
|
// Query validation
|
||||||
|
const queryValidation = [
|
||||||
|
query('page')
|
||||||
|
.optional()
|
||||||
|
.isInt({ min: 1 })
|
||||||
|
.withMessage('Page must be a positive integer'),
|
||||||
|
query('limit')
|
||||||
|
.optional()
|
||||||
|
.isInt({ min: 1, max: 100 })
|
||||||
|
.withMessage('Limit must be between 1 and 100'),
|
||||||
|
query('status')
|
||||||
|
.optional()
|
||||||
|
.isIn(['draft', 'pending', 'confirmed', 'processing', 'completed', 'cancelled', 'refunded'])
|
||||||
|
.withMessage('Invalid status'),
|
||||||
|
query('customerId')
|
||||||
|
.optional()
|
||||||
|
.isUUID()
|
||||||
|
.withMessage('Customer ID must be a valid UUID'),
|
||||||
|
query('startDate')
|
||||||
|
.optional()
|
||||||
|
.isISO8601()
|
||||||
|
.withMessage('Start date must be valid ISO 8601 date'),
|
||||||
|
query('endDate')
|
||||||
|
.optional()
|
||||||
|
.isISO8601()
|
||||||
|
.withMessage('End date must be valid ISO 8601 date')
|
||||||
|
];
|
||||||
|
|
||||||
|
// All routes require authentication
|
||||||
|
router.use(authorize('reseller'));
|
||||||
|
|
||||||
|
// Order management routes
|
||||||
|
router.get('/', queryValidation, handleValidationErrors, ordersController.getOrders);
|
||||||
|
router.post('/', orderValidation, handleValidationErrors, ordersController.createOrder);
|
||||||
|
router.get('/pending', ordersController.getPendingOrders);
|
||||||
|
|
||||||
|
// Individual order routes
|
||||||
|
router.get('/:orderId', uuidValidation, handleValidationErrors, ordersController.getOrderDetails);
|
||||||
|
router.put('/:orderId/status', [
|
||||||
|
...uuidValidation,
|
||||||
|
...statusUpdateValidation
|
||||||
|
], handleValidationErrors, ordersController.updateOrderStatus);
|
||||||
|
router.post('/:orderId/cancel', [
|
||||||
|
...uuidValidation,
|
||||||
|
...cancelValidation
|
||||||
|
], handleValidationErrors, ordersController.cancelOrder);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
45
src/routes/products.js
Normal file
45
src/routes/products.js
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const { body, validationResult } = require('express-validator');
|
||||||
|
const { authorize } = require('../middleware/auth');
|
||||||
|
const productController = require('../controllers/productController');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Validation middleware
|
||||||
|
const handleValidationErrors = (req, res, next) => {
|
||||||
|
const errors = validationResult(req);
|
||||||
|
if (!errors.isEmpty()) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Validation failed',
|
||||||
|
errors: errors.array()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get all products with reseller pricing
|
||||||
|
router.get('/', productController.getProducts);
|
||||||
|
|
||||||
|
// Get product categories
|
||||||
|
router.get('/categories', productController.getCategories);
|
||||||
|
|
||||||
|
// Get specific product details
|
||||||
|
router.get('/:productId', productController.getProduct);
|
||||||
|
|
||||||
|
// Update custom pricing for a product
|
||||||
|
router.put('/:productId/pricing', [
|
||||||
|
body('pricingType')
|
||||||
|
.isIn(['margin', 'fixed_price'])
|
||||||
|
.withMessage('Pricing type must be margin or fixed_price'),
|
||||||
|
body('customMargin')
|
||||||
|
.if(body('pricingType').equals('margin'))
|
||||||
|
.isFloat({ min: 0, max: 100 })
|
||||||
|
.withMessage('Custom margin must be between 0 and 100'),
|
||||||
|
body('customPrice')
|
||||||
|
.if(body('pricingType').equals('fixed_price'))
|
||||||
|
.isFloat({ min: 0 })
|
||||||
|
.withMessage('Custom price must be greater than 0')
|
||||||
|
], handleValidationErrors, productController.updateProductPricing);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
139
src/routes/provisioning.js
Normal file
139
src/routes/provisioning.js
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const { body, param, query, validationResult } = require('express-validator');
|
||||||
|
const { authorize } = require('../middleware/auth');
|
||||||
|
const provisioningController = require('../controllers/provisioningController');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Validation middleware
|
||||||
|
const handleValidationErrors = (req, res, next) => {
|
||||||
|
const errors = validationResult(req);
|
||||||
|
if (!errors.isEmpty()) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Validation failed',
|
||||||
|
errors: errors.array()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Instance creation validation
|
||||||
|
const instanceValidation = [
|
||||||
|
body('customerId')
|
||||||
|
.isUUID()
|
||||||
|
.withMessage('Customer ID must be a valid UUID'),
|
||||||
|
body('name')
|
||||||
|
.trim()
|
||||||
|
.isLength({ min: 2, max: 50 })
|
||||||
|
.withMessage('Instance name must be 2-50 characters'),
|
||||||
|
body('template')
|
||||||
|
.trim()
|
||||||
|
.notEmpty()
|
||||||
|
.withMessage('Template is required'),
|
||||||
|
body('size')
|
||||||
|
.trim()
|
||||||
|
.notEmpty()
|
||||||
|
.withMessage('Instance size is required'),
|
||||||
|
body('region')
|
||||||
|
.trim()
|
||||||
|
.notEmpty()
|
||||||
|
.withMessage('Region is required'),
|
||||||
|
body('specifications')
|
||||||
|
.optional()
|
||||||
|
.isObject()
|
||||||
|
.withMessage('Specifications must be an object'),
|
||||||
|
body('configuration')
|
||||||
|
.optional()
|
||||||
|
.isObject()
|
||||||
|
.withMessage('Configuration must be an object'),
|
||||||
|
body('tags')
|
||||||
|
.optional()
|
||||||
|
.isArray()
|
||||||
|
.withMessage('Tags must be an array')
|
||||||
|
];
|
||||||
|
|
||||||
|
// Snapshot validation
|
||||||
|
const snapshotValidation = [
|
||||||
|
body('name')
|
||||||
|
.trim()
|
||||||
|
.isLength({ min: 2, max: 50 })
|
||||||
|
.withMessage('Snapshot name must be 2-50 characters'),
|
||||||
|
body('description')
|
||||||
|
.optional()
|
||||||
|
.trim()
|
||||||
|
.isLength({ max: 255 })
|
||||||
|
.withMessage('Description must be less than 255 characters'),
|
||||||
|
body('type')
|
||||||
|
.optional()
|
||||||
|
.isIn(['manual', 'automatic', 'backup'])
|
||||||
|
.withMessage('Invalid snapshot type')
|
||||||
|
];
|
||||||
|
|
||||||
|
// UUID parameter validation
|
||||||
|
const uuidValidation = [
|
||||||
|
param('instanceId')
|
||||||
|
.isUUID()
|
||||||
|
.withMessage('Instance ID must be a valid UUID')
|
||||||
|
];
|
||||||
|
|
||||||
|
// Query validation
|
||||||
|
const queryValidation = [
|
||||||
|
query('page')
|
||||||
|
.optional()
|
||||||
|
.isInt({ min: 1 })
|
||||||
|
.withMessage('Page must be a positive integer'),
|
||||||
|
query('limit')
|
||||||
|
.optional()
|
||||||
|
.isInt({ min: 1, max: 100 })
|
||||||
|
.withMessage('Limit must be between 1 and 100'),
|
||||||
|
query('status')
|
||||||
|
.optional()
|
||||||
|
.isIn(['pending', 'creating', 'running', 'stopped', 'stopping', 'starting', 'failed', 'terminated'])
|
||||||
|
.withMessage('Invalid status'),
|
||||||
|
query('type')
|
||||||
|
.optional()
|
||||||
|
.isIn(['compute', 'storage', 'network', 'database'])
|
||||||
|
.withMessage('Invalid instance type'),
|
||||||
|
query('customerId')
|
||||||
|
.optional()
|
||||||
|
.isUUID()
|
||||||
|
.withMessage('Customer ID must be a valid UUID'),
|
||||||
|
query('region')
|
||||||
|
.optional()
|
||||||
|
.trim()
|
||||||
|
.notEmpty()
|
||||||
|
.withMessage('Region cannot be empty')
|
||||||
|
];
|
||||||
|
|
||||||
|
// All routes require authentication
|
||||||
|
router.use(authorize('reseller'));
|
||||||
|
|
||||||
|
// Instance management routes
|
||||||
|
router.get('/', queryValidation, handleValidationErrors, provisioningController.getInstances);
|
||||||
|
router.post('/', instanceValidation, handleValidationErrors, provisioningController.createInstance);
|
||||||
|
|
||||||
|
// Instance control routes
|
||||||
|
router.post('/:instanceId/start', uuidValidation, handleValidationErrors, provisioningController.startInstance);
|
||||||
|
router.post('/:instanceId/stop', uuidValidation, handleValidationErrors, provisioningController.stopInstance);
|
||||||
|
|
||||||
|
// Snapshot management
|
||||||
|
router.post('/:instanceId/snapshots', [
|
||||||
|
...uuidValidation,
|
||||||
|
...snapshotValidation
|
||||||
|
], handleValidationErrors, provisioningController.createSnapshot);
|
||||||
|
|
||||||
|
// Instance events
|
||||||
|
router.get('/:instanceId/events', [
|
||||||
|
...uuidValidation,
|
||||||
|
query('page')
|
||||||
|
.optional()
|
||||||
|
.isInt({ min: 1 })
|
||||||
|
.withMessage('Page must be a positive integer'),
|
||||||
|
query('limit')
|
||||||
|
.optional()
|
||||||
|
.isInt({ min: 1, max: 100 })
|
||||||
|
.withMessage('Limit must be between 1 and 100')
|
||||||
|
], handleValidationErrors, provisioningController.getInstanceEvents);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
80
src/routes/reports.js
Normal file
80
src/routes/reports.js
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const { query, validationResult } = require('express-validator');
|
||||||
|
const { authorize } = require('../middleware/auth');
|
||||||
|
const reportsController = require('../controllers/reportsController');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Validation middleware
|
||||||
|
const handleValidationErrors = (req, res, next) => {
|
||||||
|
const errors = validationResult(req);
|
||||||
|
if (!errors.isEmpty()) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Validation failed',
|
||||||
|
errors: errors.array()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Date range validation
|
||||||
|
const dateRangeValidation = [
|
||||||
|
query('startDate')
|
||||||
|
.optional()
|
||||||
|
.isISO8601()
|
||||||
|
.withMessage('Start date must be a valid ISO 8601 date'),
|
||||||
|
query('endDate')
|
||||||
|
.optional()
|
||||||
|
.isISO8601()
|
||||||
|
.withMessage('End date must be a valid ISO 8601 date'),
|
||||||
|
query('groupBy')
|
||||||
|
.optional()
|
||||||
|
.isIn(['day', 'week', 'month', 'quarter', 'year'])
|
||||||
|
.withMessage('Group by must be day, week, month, quarter, or year'),
|
||||||
|
query('format')
|
||||||
|
.optional()
|
||||||
|
.isIn(['json', 'csv'])
|
||||||
|
.withMessage('Format must be json or csv')
|
||||||
|
];
|
||||||
|
|
||||||
|
// Export validation
|
||||||
|
const exportValidation = [
|
||||||
|
query('reportType')
|
||||||
|
.isIn(['sales', 'usage', 'commission'])
|
||||||
|
.withMessage('Report type must be sales, usage, or commission'),
|
||||||
|
query('format')
|
||||||
|
.optional()
|
||||||
|
.isIn(['json', 'csv', 'pdf'])
|
||||||
|
.withMessage('Format must be json, csv, or pdf')
|
||||||
|
];
|
||||||
|
|
||||||
|
// Sales report
|
||||||
|
router.get('/sales', dateRangeValidation, handleValidationErrors, reportsController.getSalesReport);
|
||||||
|
|
||||||
|
// Usage report
|
||||||
|
router.get('/usage', [
|
||||||
|
...dateRangeValidation,
|
||||||
|
query('customerId')
|
||||||
|
.optional()
|
||||||
|
.isUUID()
|
||||||
|
.withMessage('Customer ID must be a valid UUID'),
|
||||||
|
query('productId')
|
||||||
|
.optional()
|
||||||
|
.isUUID()
|
||||||
|
.withMessage('Product ID must be a valid UUID')
|
||||||
|
], handleValidationErrors, reportsController.getUsageReport);
|
||||||
|
|
||||||
|
// Commission report
|
||||||
|
router.get('/commission', [
|
||||||
|
...dateRangeValidation,
|
||||||
|
query('status')
|
||||||
|
.optional()
|
||||||
|
.isIn(['all', 'pending', 'approved', 'paid', 'disputed', 'cancelled'])
|
||||||
|
.withMessage('Invalid commission status')
|
||||||
|
], handleValidationErrors, reportsController.getCommissionReport);
|
||||||
|
|
||||||
|
// Export reports
|
||||||
|
router.get('/export', exportValidation, handleValidationErrors, reportsController.exportReport);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
382
src/routes/resellers.js
Normal file
382
src/routes/resellers.js
Normal file
@ -0,0 +1,382 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const { body, validationResult } = require('express-validator');
|
||||||
|
const { authorize } = require('../middleware/auth');
|
||||||
|
const { kycUpload, extractMultipleFileInfo } = require('../middleware/upload');
|
||||||
|
const { Reseller, User, AuditLog } = require('../models');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Validation middleware
|
||||||
|
const handleValidationErrors = (req, res, next) => {
|
||||||
|
const errors = validationResult(req);
|
||||||
|
if (!errors.isEmpty()) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Validation failed',
|
||||||
|
errors: errors.array()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get reseller profile
|
||||||
|
router.get('/profile', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const reseller = await Reseller.findOne({
|
||||||
|
where: { id: req.user.resellerId },
|
||||||
|
include: ['users', 'approver']
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!reseller) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Reseller profile not found'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: { reseller }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get reseller profile error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to fetch reseller profile'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update reseller profile
|
||||||
|
router.put('/profile', [
|
||||||
|
body('companyName')
|
||||||
|
.optional()
|
||||||
|
.trim()
|
||||||
|
.isLength({ min: 2, max: 100 })
|
||||||
|
.withMessage('Company name must be 2-100 characters'),
|
||||||
|
body('companyType')
|
||||||
|
.optional()
|
||||||
|
.isIn(['individual', 'partnership', 'private_limited', 'public_limited', 'llp'])
|
||||||
|
.withMessage('Invalid company type'),
|
||||||
|
body('gstNumber')
|
||||||
|
.optional()
|
||||||
|
.matches(/^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z]{1}[1-9A-Z]{1}Z[0-9A-Z]{1}$/)
|
||||||
|
.withMessage('Invalid GST number format'),
|
||||||
|
body('panNumber')
|
||||||
|
.optional()
|
||||||
|
.matches(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)
|
||||||
|
.withMessage('Invalid PAN number format'),
|
||||||
|
body('contactEmail')
|
||||||
|
.optional()
|
||||||
|
.isEmail()
|
||||||
|
.withMessage('Invalid email format'),
|
||||||
|
body('contactPhone')
|
||||||
|
.optional()
|
||||||
|
.matches(/^[\+]?[1-9][\d]{0,15}$/)
|
||||||
|
.withMessage('Invalid phone number format'),
|
||||||
|
body('website')
|
||||||
|
.optional()
|
||||||
|
.isURL()
|
||||||
|
.withMessage('Invalid website URL')
|
||||||
|
], handleValidationErrors, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
companyName,
|
||||||
|
companyType,
|
||||||
|
registrationNumber,
|
||||||
|
gstNumber,
|
||||||
|
panNumber,
|
||||||
|
address,
|
||||||
|
contactEmail,
|
||||||
|
contactPhone,
|
||||||
|
website
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
const reseller = await Reseller.findOne({
|
||||||
|
where: { id: req.user.resellerId }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!reseller) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Reseller profile not found'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates = {};
|
||||||
|
if (companyName !== undefined) updates.companyName = companyName;
|
||||||
|
if (companyType !== undefined) updates.companyType = companyType;
|
||||||
|
if (registrationNumber !== undefined) updates.registrationNumber = registrationNumber;
|
||||||
|
if (gstNumber !== undefined) updates.gstNumber = gstNumber;
|
||||||
|
if (panNumber !== undefined) updates.panNumber = panNumber;
|
||||||
|
if (address !== undefined) updates.address = address;
|
||||||
|
if (contactEmail !== undefined) updates.contactEmail = contactEmail;
|
||||||
|
if (contactPhone !== undefined) updates.contactPhone = contactPhone;
|
||||||
|
if (website !== undefined) updates.website = website;
|
||||||
|
|
||||||
|
await reseller.update(updates);
|
||||||
|
|
||||||
|
// Log audit
|
||||||
|
await AuditLog.create({
|
||||||
|
userId: req.user.id,
|
||||||
|
action: 'RESELLER_PROFILE_UPDATED',
|
||||||
|
resource: 'reseller',
|
||||||
|
resourceId: reseller.id,
|
||||||
|
newValues: updates,
|
||||||
|
ipAddress: req.ip,
|
||||||
|
userAgent: req.get('User-Agent')
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Reseller profile updated successfully',
|
||||||
|
data: { reseller }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Update reseller profile error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to update reseller profile'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Upload KYC documents
|
||||||
|
router.post('/kyc-documents', kycUpload.fields([
|
||||||
|
{ name: 'gstCertificate', maxCount: 1 },
|
||||||
|
{ name: 'panCard', maxCount: 1 },
|
||||||
|
{ name: 'incorporationCertificate', maxCount: 1 },
|
||||||
|
{ name: 'addressProof', maxCount: 1 },
|
||||||
|
{ name: 'bankStatement', maxCount: 1 }
|
||||||
|
]), async (req, res) => {
|
||||||
|
try {
|
||||||
|
if (!req.files || Object.keys(req.files).length === 0) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'No KYC documents uploaded'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const reseller = await Reseller.findOne({
|
||||||
|
where: { id: req.user.resellerId }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!reseller) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Reseller profile not found'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadedFiles = extractMultipleFileInfo(req.files);
|
||||||
|
const currentDocuments = reseller.kycDocuments || [];
|
||||||
|
|
||||||
|
// Add new documents
|
||||||
|
Object.keys(uploadedFiles).forEach(docType => {
|
||||||
|
uploadedFiles[docType].forEach(file => {
|
||||||
|
currentDocuments.push({
|
||||||
|
type: docType,
|
||||||
|
...file,
|
||||||
|
status: 'submitted'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await reseller.update({
|
||||||
|
kycDocuments: currentDocuments,
|
||||||
|
kycStatus: 'submitted'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log audit
|
||||||
|
await AuditLog.create({
|
||||||
|
userId: req.user.id,
|
||||||
|
action: 'KYC_DOCUMENTS_UPLOADED',
|
||||||
|
resource: 'reseller',
|
||||||
|
resourceId: reseller.id,
|
||||||
|
metadata: { documentTypes: Object.keys(uploadedFiles) },
|
||||||
|
ipAddress: req.ip,
|
||||||
|
userAgent: req.get('User-Agent')
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'KYC documents uploaded successfully',
|
||||||
|
data: {
|
||||||
|
uploadedFiles,
|
||||||
|
kycStatus: reseller.kycStatus
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('KYC upload error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to upload KYC documents'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get all resellers (admin only)
|
||||||
|
router.get('/', authorize('reseller_admin'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { page = 1, limit = 10, status, tier, kycStatus, search } = req.query;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
const where = {};
|
||||||
|
if (status) where.status = status;
|
||||||
|
if (tier) where.tier = tier;
|
||||||
|
if (kycStatus) where.kycStatus = kycStatus;
|
||||||
|
if (search) {
|
||||||
|
where[require('sequelize').Op.or] = [
|
||||||
|
{ companyName: { [require('sequelize').Op.iLike]: `%${search}%` } },
|
||||||
|
{ contactEmail: { [require('sequelize').Op.iLike]: `%${search}%` } },
|
||||||
|
{ gstNumber: { [require('sequelize').Op.iLike]: `%${search}%` } }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const { count, rows: resellers } = await Reseller.findAndCountAll({
|
||||||
|
where,
|
||||||
|
include: ['users', 'approver'],
|
||||||
|
limit: parseInt(limit),
|
||||||
|
offset: parseInt(offset),
|
||||||
|
order: [['createdAt', 'DESC']]
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
resellers,
|
||||||
|
pagination: {
|
||||||
|
total: count,
|
||||||
|
page: parseInt(page),
|
||||||
|
pages: Math.ceil(count / limit),
|
||||||
|
limit: parseInt(limit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get resellers error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to fetch resellers'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Approve/reject reseller (admin only)
|
||||||
|
router.put('/:resellerId/approval', authorize('reseller_admin'), [
|
||||||
|
body('action')
|
||||||
|
.isIn(['approve', 'reject'])
|
||||||
|
.withMessage('Action must be approve or reject'),
|
||||||
|
body('rejectionReason')
|
||||||
|
.if(body('action').equals('reject'))
|
||||||
|
.notEmpty()
|
||||||
|
.withMessage('Rejection reason is required when rejecting')
|
||||||
|
], handleValidationErrors, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { resellerId } = req.params;
|
||||||
|
const { action, rejectionReason } = req.body;
|
||||||
|
|
||||||
|
const reseller = await Reseller.findByPk(resellerId);
|
||||||
|
if (!reseller) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Reseller not found'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates = {
|
||||||
|
approvedBy: req.user.id,
|
||||||
|
approvedAt: new Date()
|
||||||
|
};
|
||||||
|
|
||||||
|
if (action === 'approve') {
|
||||||
|
updates.status = 'active';
|
||||||
|
updates.kycStatus = 'approved';
|
||||||
|
} else {
|
||||||
|
updates.status = 'rejected';
|
||||||
|
updates.kycStatus = 'rejected';
|
||||||
|
updates.rejectionReason = rejectionReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
await reseller.update(updates);
|
||||||
|
|
||||||
|
// Log audit
|
||||||
|
await AuditLog.create({
|
||||||
|
userId: req.user.id,
|
||||||
|
action: `RESELLER_${action.toUpperCase()}D`,
|
||||||
|
resource: 'reseller',
|
||||||
|
resourceId: resellerId,
|
||||||
|
newValues: updates,
|
||||||
|
ipAddress: req.ip,
|
||||||
|
userAgent: req.get('User-Agent')
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: `Reseller ${action}d successfully`,
|
||||||
|
data: { reseller }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Reseller approval error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to process reseller approval'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update reseller tier (admin only)
|
||||||
|
router.put('/:resellerId/tier', authorize('reseller_admin'), [
|
||||||
|
body('tier')
|
||||||
|
.isIn(['bronze', 'silver', 'gold', 'platinum', 'diamond'])
|
||||||
|
.withMessage('Invalid tier'),
|
||||||
|
body('commissionRate')
|
||||||
|
.optional()
|
||||||
|
.isFloat({ min: 0, max: 100 })
|
||||||
|
.withMessage('Commission rate must be between 0 and 100')
|
||||||
|
], handleValidationErrors, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { resellerId } = req.params;
|
||||||
|
const { tier, commissionRate } = req.body;
|
||||||
|
|
||||||
|
const reseller = await Reseller.findByPk(resellerId);
|
||||||
|
if (!reseller) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Reseller not found'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates = { tier };
|
||||||
|
if (commissionRate !== undefined) {
|
||||||
|
updates.commissionRate = commissionRate;
|
||||||
|
}
|
||||||
|
|
||||||
|
await reseller.update(updates);
|
||||||
|
|
||||||
|
// Log audit
|
||||||
|
await AuditLog.create({
|
||||||
|
userId: req.user.id,
|
||||||
|
action: 'RESELLER_TIER_UPDATED',
|
||||||
|
resource: 'reseller',
|
||||||
|
resourceId: resellerId,
|
||||||
|
newValues: updates,
|
||||||
|
ipAddress: req.ip,
|
||||||
|
userAgent: req.get('User-Agent')
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Reseller tier updated successfully',
|
||||||
|
data: { reseller }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Update reseller tier error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to update reseller tier'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
298
src/routes/users.js
Normal file
298
src/routes/users.js
Normal file
@ -0,0 +1,298 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const { body, validationResult } = require('express-validator');
|
||||||
|
const { authorize } = require('../middleware/auth');
|
||||||
|
const { avatarUpload, extractFileInfo } = require('../middleware/upload');
|
||||||
|
const { User, AuditLog } = require('../models');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Validation middleware
|
||||||
|
const handleValidationErrors = (req, res, next) => {
|
||||||
|
const errors = validationResult(req);
|
||||||
|
if (!errors.isEmpty()) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Validation failed',
|
||||||
|
errors: errors.array()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get current user profile
|
||||||
|
router.get('/profile', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const user = await User.findByPk(req.user.id, {
|
||||||
|
include: ['reseller']
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: { user: user.toJSON() }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get profile error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to fetch profile'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update user profile
|
||||||
|
router.put('/profile', [
|
||||||
|
body('firstName')
|
||||||
|
.optional()
|
||||||
|
.trim()
|
||||||
|
.isLength({ min: 2, max: 50 })
|
||||||
|
.withMessage('First name must be 2-50 characters'),
|
||||||
|
body('lastName')
|
||||||
|
.optional()
|
||||||
|
.trim()
|
||||||
|
.isLength({ min: 2, max: 50 })
|
||||||
|
.withMessage('Last name must be 2-50 characters'),
|
||||||
|
body('phone')
|
||||||
|
.optional()
|
||||||
|
.matches(/^[\+]?[1-9][\d]{0,15}$/)
|
||||||
|
.withMessage('Invalid phone number format'),
|
||||||
|
body('timezone')
|
||||||
|
.optional()
|
||||||
|
.isString()
|
||||||
|
.withMessage('Invalid timezone'),
|
||||||
|
body('language')
|
||||||
|
.optional()
|
||||||
|
.isIn(['en', 'hi', 'es', 'fr'])
|
||||||
|
.withMessage('Unsupported language')
|
||||||
|
], handleValidationErrors, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { firstName, lastName, phone, timezone, language, preferences } = req.body;
|
||||||
|
const user = req.user;
|
||||||
|
|
||||||
|
const updates = {};
|
||||||
|
if (firstName !== undefined) updates.firstName = firstName;
|
||||||
|
if (lastName !== undefined) updates.lastName = lastName;
|
||||||
|
if (phone !== undefined) updates.phone = phone;
|
||||||
|
if (timezone !== undefined) updates.timezone = timezone;
|
||||||
|
if (language !== undefined) updates.language = language;
|
||||||
|
if (preferences !== undefined) updates.preferences = preferences;
|
||||||
|
|
||||||
|
await user.update(updates);
|
||||||
|
|
||||||
|
// Log audit
|
||||||
|
await AuditLog.create({
|
||||||
|
userId: user.id,
|
||||||
|
action: 'PROFILE_UPDATED',
|
||||||
|
resource: 'user',
|
||||||
|
resourceId: user.id,
|
||||||
|
newValues: updates,
|
||||||
|
ipAddress: req.ip,
|
||||||
|
userAgent: req.get('User-Agent')
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Profile updated successfully',
|
||||||
|
data: { user: user.toJSON() }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Update profile error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to update profile'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Upload avatar
|
||||||
|
router.post('/avatar', avatarUpload.single('avatar'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
if (!req.file) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'No avatar file uploaded'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = req.user;
|
||||||
|
const fileInfo = extractFileInfo(req.file);
|
||||||
|
|
||||||
|
await user.update({
|
||||||
|
avatar: `/uploads/avatars/${fileInfo.filename}`
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log audit
|
||||||
|
await AuditLog.create({
|
||||||
|
userId: user.id,
|
||||||
|
action: 'AVATAR_UPDATED',
|
||||||
|
resource: 'user',
|
||||||
|
resourceId: user.id,
|
||||||
|
metadata: { filename: fileInfo.filename },
|
||||||
|
ipAddress: req.ip,
|
||||||
|
userAgent: req.get('User-Agent')
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Avatar uploaded successfully',
|
||||||
|
data: {
|
||||||
|
avatar: user.avatar,
|
||||||
|
fileInfo
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Avatar upload error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to upload avatar'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Change password
|
||||||
|
router.put('/password', [
|
||||||
|
body('currentPassword')
|
||||||
|
.notEmpty()
|
||||||
|
.withMessage('Current password is required'),
|
||||||
|
body('newPassword')
|
||||||
|
.isLength({ min: 8 })
|
||||||
|
.matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/)
|
||||||
|
.withMessage('Password must be at least 8 characters with uppercase, lowercase, number and special character')
|
||||||
|
], handleValidationErrors, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { currentPassword, newPassword } = req.body;
|
||||||
|
const user = req.user;
|
||||||
|
|
||||||
|
// Verify current password
|
||||||
|
const isValidPassword = await user.validatePassword(currentPassword);
|
||||||
|
if (!isValidPassword) {
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Current password is incorrect'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update password
|
||||||
|
await user.update({ password: newPassword });
|
||||||
|
|
||||||
|
// Log audit
|
||||||
|
await AuditLog.create({
|
||||||
|
userId: user.id,
|
||||||
|
action: 'PASSWORD_CHANGED',
|
||||||
|
resource: 'user',
|
||||||
|
resourceId: user.id,
|
||||||
|
ipAddress: req.ip,
|
||||||
|
userAgent: req.get('User-Agent')
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Password changed successfully'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Change password error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to change password'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get all users (admin only)
|
||||||
|
router.get('/', authorize('reseller_admin'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { page = 1, limit = 10, role, status, search } = req.query;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
const where = {};
|
||||||
|
if (role) where.role = role;
|
||||||
|
if (status) where.status = status;
|
||||||
|
if (search) {
|
||||||
|
where[require('sequelize').Op.or] = [
|
||||||
|
{ firstName: { [require('sequelize').Op.iLike]: `%${search}%` } },
|
||||||
|
{ lastName: { [require('sequelize').Op.iLike]: `%${search}%` } },
|
||||||
|
{ email: { [require('sequelize').Op.iLike]: `%${search}%` } }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const { count, rows: users } = await User.findAndCountAll({
|
||||||
|
where,
|
||||||
|
include: ['reseller'],
|
||||||
|
limit: parseInt(limit),
|
||||||
|
offset: parseInt(offset),
|
||||||
|
order: [['createdAt', 'DESC']]
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
users: users.map(user => user.toJSON()),
|
||||||
|
pagination: {
|
||||||
|
total: count,
|
||||||
|
page: parseInt(page),
|
||||||
|
pages: Math.ceil(count / limit),
|
||||||
|
limit: parseInt(limit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get users error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to fetch users'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update user role/status (admin only)
|
||||||
|
router.put('/:userId/status', authorize('reseller_admin'), [
|
||||||
|
body('status')
|
||||||
|
.isIn(['active', 'inactive', 'suspended'])
|
||||||
|
.withMessage('Invalid status'),
|
||||||
|
body('role')
|
||||||
|
.optional()
|
||||||
|
.isIn(['reseller_admin', 'sales_agent', 'support_agent', 'read_only'])
|
||||||
|
.withMessage('Invalid role')
|
||||||
|
], handleValidationErrors, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { userId } = req.params;
|
||||||
|
const { status, role } = req.body;
|
||||||
|
|
||||||
|
const user = await User.findByPk(userId);
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: 'User not found'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates = { status };
|
||||||
|
if (role !== undefined) updates.role = role;
|
||||||
|
|
||||||
|
await user.update(updates);
|
||||||
|
|
||||||
|
// Log audit
|
||||||
|
await AuditLog.create({
|
||||||
|
userId: req.user.id,
|
||||||
|
action: 'USER_STATUS_UPDATED',
|
||||||
|
resource: 'user',
|
||||||
|
resourceId: userId,
|
||||||
|
newValues: updates,
|
||||||
|
ipAddress: req.ip,
|
||||||
|
userAgent: req.get('User-Agent')
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'User status updated successfully',
|
||||||
|
data: { user: user.toJSON() }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Update user status error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to update user status'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
30
src/scripts/createDatabase.js
Normal file
30
src/scripts/createDatabase.js
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
const { sequelize } = require('../models');
|
||||||
|
|
||||||
|
async function createDatabase() {
|
||||||
|
try {
|
||||||
|
console.log('🔄 Creating database tables...');
|
||||||
|
|
||||||
|
// Sync all models with database
|
||||||
|
await sequelize.sync({ force: false, alter: true });
|
||||||
|
|
||||||
|
console.log('✅ Database tables created successfully');
|
||||||
|
|
||||||
|
// Test connection
|
||||||
|
await sequelize.authenticate();
|
||||||
|
console.log('✅ Database connection verified');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Database creation failed:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run if called directly
|
||||||
|
if (require.main === module) {
|
||||||
|
createDatabase().then(() => {
|
||||||
|
console.log('🎉 Database setup completed');
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = createDatabase;
|
||||||
Loading…
Reference in New Issue
Block a user