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