From 57d8f3fd5c962971fce7d233910a4811155d0b54 Mon Sep 17 00:00:00 2001 From: rohit Date: Thu, 31 Jul 2025 08:15:46 +0530 Subject: [PATCH] initial commit --- .env.example | 50 ++ .gitignore | 101 ++++ .sequelizerc | 8 + package.json | 59 +++ src/app.js | 183 +++++++ src/config/database.js | 70 +++ src/config/email.js | 112 ++++ src/config/redis.js | 51 ++ src/controllers/adminController.js | 279 ++++++++++ src/controllers/authController.js | 350 +++++++++++++ src/controllers/billingController.js | 489 ++++++++++++++++++ src/controllers/customerController.js | 443 ++++++++++++++++ src/controllers/dashboardController.js | 408 +++++++++++++++ src/controllers/knowledgeController.js | 379 ++++++++++++++ src/controllers/legalController.js | 359 +++++++++++++ src/controllers/marketingController.js | 333 ++++++++++++ src/controllers/ordersController.js | 399 ++++++++++++++ src/controllers/productController.js | 229 ++++++++ src/controllers/provisioningController.js | 356 +++++++++++++ src/controllers/reportsController.js | 279 ++++++++++ src/controllers/trainingController.js | 421 +++++++++++++++ src/middleware/auth.js | 191 +++++++ src/middleware/errorHandler.js | 77 +++ src/middleware/upload.js | 151 ++++++ src/migrations/20250130000001-create-users.js | 119 +++++ .../20250130000002-create-resellers.js | 140 +++++ .../20250130000003-create-products.js | 131 +++++ .../20250130000004-create-customers.js | 103 ++++ .../20250130000005-create-wallets.js | 85 +++ .../20250130000006-create-invoices.js | 124 +++++ .../20250130000007-create-orders.js | 95 ++++ src/models/AssetDownload.js | 90 ++++ src/models/AuditLog.js | 78 +++ src/models/Certificate.js | 194 +++++++ src/models/Commission.js | 128 +++++ src/models/Course.js | 194 +++++++ src/models/CourseEnrollment.js | 173 +++++++ src/models/Customer.js | 246 +++++++++ src/models/CustomerService.js | 265 ++++++++++ src/models/Instance.js | 198 +++++++ src/models/InstanceEvent.js | 109 ++++ src/models/InstanceSnapshot.js | 101 ++++ src/models/Invoice.js | 291 +++++++++++ src/models/InvoiceItem.js | 112 ++++ src/models/KnowledgeArticle.js | 207 ++++++++ src/models/LegalAcceptance.js | 162 ++++++ src/models/LegalDocument.js | 150 ++++++ src/models/MarketingAsset.js | 167 ++++++ src/models/Order.js | 126 +++++ src/models/OrderItem.js | 75 +++ src/models/Product.js | 188 +++++++ src/models/Reseller.js | 246 +++++++++ src/models/ResellerPricing.js | 176 +++++++ src/models/ServiceAlert.js | 88 ++++ src/models/UsageRecord.js | 179 +++++++ src/models/User.js | 260 ++++++++++ src/models/UserSession.js | 86 +++ src/models/Wallet.js | 207 ++++++++ src/models/WalletTransaction.js | 242 +++++++++ src/models/index.js | 41 ++ src/routes/admin.js | 60 +++ src/routes/auth.js | 112 ++++ src/routes/billing.js | 72 +++ src/routes/customers.js | 129 +++++ src/routes/dashboard.js | 75 +++ src/routes/legal.js | 127 +++++ src/routes/marketing.js | 126 +++++ src/routes/orders.js | 132 +++++ src/routes/products.js | 45 ++ src/routes/provisioning.js | 139 +++++ src/routes/reports.js | 80 +++ src/routes/resellers.js | 382 ++++++++++++++ src/routes/users.js | 298 +++++++++++ src/scripts/createDatabase.js | 30 ++ 74 files changed, 13160 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 .sequelizerc create mode 100644 package.json create mode 100644 src/app.js create mode 100644 src/config/database.js create mode 100644 src/config/email.js create mode 100644 src/config/redis.js create mode 100644 src/controllers/adminController.js create mode 100644 src/controllers/authController.js create mode 100644 src/controllers/billingController.js create mode 100644 src/controllers/customerController.js create mode 100644 src/controllers/dashboardController.js create mode 100644 src/controllers/knowledgeController.js create mode 100644 src/controllers/legalController.js create mode 100644 src/controllers/marketingController.js create mode 100644 src/controllers/ordersController.js create mode 100644 src/controllers/productController.js create mode 100644 src/controllers/provisioningController.js create mode 100644 src/controllers/reportsController.js create mode 100644 src/controllers/trainingController.js create mode 100644 src/middleware/auth.js create mode 100644 src/middleware/errorHandler.js create mode 100644 src/middleware/upload.js create mode 100644 src/migrations/20250130000001-create-users.js create mode 100644 src/migrations/20250130000002-create-resellers.js create mode 100644 src/migrations/20250130000003-create-products.js create mode 100644 src/migrations/20250130000004-create-customers.js create mode 100644 src/migrations/20250130000005-create-wallets.js create mode 100644 src/migrations/20250130000006-create-invoices.js create mode 100644 src/migrations/20250130000007-create-orders.js create mode 100644 src/models/AssetDownload.js create mode 100644 src/models/AuditLog.js create mode 100644 src/models/Certificate.js create mode 100644 src/models/Commission.js create mode 100644 src/models/Course.js create mode 100644 src/models/CourseEnrollment.js create mode 100644 src/models/Customer.js create mode 100644 src/models/CustomerService.js create mode 100644 src/models/Instance.js create mode 100644 src/models/InstanceEvent.js create mode 100644 src/models/InstanceSnapshot.js create mode 100644 src/models/Invoice.js create mode 100644 src/models/InvoiceItem.js create mode 100644 src/models/KnowledgeArticle.js create mode 100644 src/models/LegalAcceptance.js create mode 100644 src/models/LegalDocument.js create mode 100644 src/models/MarketingAsset.js create mode 100644 src/models/Order.js create mode 100644 src/models/OrderItem.js create mode 100644 src/models/Product.js create mode 100644 src/models/Reseller.js create mode 100644 src/models/ResellerPricing.js create mode 100644 src/models/ServiceAlert.js create mode 100644 src/models/UsageRecord.js create mode 100644 src/models/User.js create mode 100644 src/models/UserSession.js create mode 100644 src/models/Wallet.js create mode 100644 src/models/WalletTransaction.js create mode 100644 src/models/index.js create mode 100644 src/routes/admin.js create mode 100644 src/routes/auth.js create mode 100644 src/routes/billing.js create mode 100644 src/routes/customers.js create mode 100644 src/routes/dashboard.js create mode 100644 src/routes/legal.js create mode 100644 src/routes/marketing.js create mode 100644 src/routes/orders.js create mode 100644 src/routes/products.js create mode 100644 src/routes/provisioning.js create mode 100644 src/routes/reports.js create mode 100644 src/routes/resellers.js create mode 100644 src/routes/users.js create mode 100644 src/scripts/createDatabase.js diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5a76bc6 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f231c90 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.sequelizerc b/.sequelizerc new file mode 100644 index 0000000..7eb2fc9 --- /dev/null +++ b/.sequelizerc @@ -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') +}; diff --git a/package.json b/package.json new file mode 100644 index 0000000..2a056e5 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/app.js b/src/app.js new file mode 100644 index 0000000..3b8a9e9 --- /dev/null +++ b/src/app.js @@ -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; diff --git a/src/config/database.js b/src/config/database.js new file mode 100644 index 0000000..5b4bd05 --- /dev/null +++ b/src/config/database.js @@ -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 + } + } +}; diff --git a/src/config/email.js b/src/config/email.js new file mode 100644 index 0000000..ff7ed3e --- /dev/null +++ b/src/config/email.js @@ -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: ` +
+

Welcome to Cloudtopiaa Reseller Portal

+

Hello ${name},

+

Welcome to the Cloudtopiaa Reseller Portal! Your account has been successfully created.

+

You can now access your dashboard and start managing your cloud services.

+ Login to Portal +

If you have any questions, please don't hesitate to contact our support team.

+

Best regards,
Cloudtopiaa Team

+
+ ` + }), + + passwordReset: (name, resetUrl, otp) => ({ + subject: 'Password Reset Request - Cloudtopiaa Reseller Portal', + html: ` +
+

Password Reset Request

+

Hello ${name},

+

We received a request to reset your password for your Cloudtopiaa Reseller Portal account.

+

Your OTP code is: ${otp}

+

This code will expire in 5 minutes.

+

Alternatively, you can click the link below to reset your password:

+ Reset Password +

If you didn't request this password reset, please ignore this email.

+

Best regards,
Cloudtopiaa Team

+
+ ` + }), + + emailVerification: (name, verificationUrl, otp) => ({ + subject: 'Email Verification - Cloudtopiaa Reseller Portal', + html: ` +
+

Email Verification

+

Hello ${name},

+

Please verify your email address to complete your registration.

+

Your verification code is: ${otp}

+

This code will expire in 5 minutes.

+

Alternatively, you can click the link below to verify your email:

+ Verify Email +

Best regards,
Cloudtopiaa Team

+
+ ` + }), + + mfaSetup: (name, qrCodeUrl, backupCodes) => ({ + subject: 'Two-Factor Authentication Setup - Cloudtopiaa Reseller Portal', + html: ` +
+

Two-Factor Authentication Setup

+

Hello ${name},

+

You have successfully enabled two-factor authentication for your account.

+

Please save these backup codes in a secure location:

+
+ ${backupCodes.map(code => `

${code}

`).join('')} +
+

These codes can be used to access your account if you lose access to your authenticator app.

+

Best regards,
Cloudtopiaa Team

+
+ ` + }) +}; + +// 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 +}; diff --git a/src/config/redis.js b/src/config/redis.js new file mode 100644 index 0000000..4959b69 --- /dev/null +++ b/src/config/redis.js @@ -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; diff --git a/src/controllers/adminController.js b/src/controllers/adminController.js new file mode 100644 index 0000000..95251bd --- /dev/null +++ b/src/controllers/adminController.js @@ -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 +}; diff --git a/src/controllers/authController.js b/src/controllers/authController.js new file mode 100644 index 0000000..8660925 --- /dev/null +++ b/src/controllers/authController.js @@ -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 +}; diff --git a/src/controllers/billingController.js b/src/controllers/billingController.js new file mode 100644 index 0000000..2801b24 --- /dev/null +++ b/src/controllers/billingController.js @@ -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 +}; diff --git a/src/controllers/customerController.js b/src/controllers/customerController.js new file mode 100644 index 0000000..7af4bf9 --- /dev/null +++ b/src/controllers/customerController.js @@ -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 +}; diff --git a/src/controllers/dashboardController.js b/src/controllers/dashboardController.js new file mode 100644 index 0000000..3de80e0 --- /dev/null +++ b/src/controllers/dashboardController.js @@ -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 +}; diff --git a/src/controllers/knowledgeController.js b/src/controllers/knowledgeController.js new file mode 100644 index 0000000..fd221eb --- /dev/null +++ b/src/controllers/knowledgeController.js @@ -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 +}; diff --git a/src/controllers/legalController.js b/src/controllers/legalController.js new file mode 100644 index 0000000..48564db --- /dev/null +++ b/src/controllers/legalController.js @@ -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 +}; diff --git a/src/controllers/marketingController.js b/src/controllers/marketingController.js new file mode 100644 index 0000000..73094c4 --- /dev/null +++ b/src/controllers/marketingController.js @@ -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 +}; diff --git a/src/controllers/ordersController.js b/src/controllers/ordersController.js new file mode 100644 index 0000000..1e67c18 --- /dev/null +++ b/src/controllers/ordersController.js @@ -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 +}; diff --git a/src/controllers/productController.js b/src/controllers/productController.js new file mode 100644 index 0000000..76ded96 --- /dev/null +++ b/src/controllers/productController.js @@ -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 +}; diff --git a/src/controllers/provisioningController.js b/src/controllers/provisioningController.js new file mode 100644 index 0000000..095512d --- /dev/null +++ b/src/controllers/provisioningController.js @@ -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 +}; diff --git a/src/controllers/reportsController.js b/src/controllers/reportsController.js new file mode 100644 index 0000000..7c5188b --- /dev/null +++ b/src/controllers/reportsController.js @@ -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 +}; diff --git a/src/controllers/trainingController.js b/src/controllers/trainingController.js new file mode 100644 index 0000000..c45d179 --- /dev/null +++ b/src/controllers/trainingController.js @@ -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 +}; diff --git a/src/middleware/auth.js b/src/middleware/auth.js new file mode 100644 index 0000000..245cc48 --- /dev/null +++ b/src/middleware/auth.js @@ -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 +}; diff --git a/src/middleware/errorHandler.js b/src/middleware/errorHandler.js new file mode 100644 index 0000000..be89b35 --- /dev/null +++ b/src/middleware/errorHandler.js @@ -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; diff --git a/src/middleware/upload.js b/src/middleware/upload.js new file mode 100644 index 0000000..1b4b620 --- /dev/null +++ b/src/middleware/upload.js @@ -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 +}; diff --git a/src/migrations/20250130000001-create-users.js b/src/migrations/20250130000001-create-users.js new file mode 100644 index 0000000..3542b92 --- /dev/null +++ b/src/migrations/20250130000001-create-users.js @@ -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'); + } +}; diff --git a/src/migrations/20250130000002-create-resellers.js b/src/migrations/20250130000002-create-resellers.js new file mode 100644 index 0000000..ee22ac0 --- /dev/null +++ b/src/migrations/20250130000002-create-resellers.js @@ -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'); + } +}; diff --git a/src/migrations/20250130000003-create-products.js b/src/migrations/20250130000003-create-products.js new file mode 100644 index 0000000..46ace43 --- /dev/null +++ b/src/migrations/20250130000003-create-products.js @@ -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'); + } +}; diff --git a/src/migrations/20250130000004-create-customers.js b/src/migrations/20250130000004-create-customers.js new file mode 100644 index 0000000..45166d9 --- /dev/null +++ b/src/migrations/20250130000004-create-customers.js @@ -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'); + } +}; diff --git a/src/migrations/20250130000005-create-wallets.js b/src/migrations/20250130000005-create-wallets.js new file mode 100644 index 0000000..c34b333 --- /dev/null +++ b/src/migrations/20250130000005-create-wallets.js @@ -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'); + } +}; diff --git a/src/migrations/20250130000006-create-invoices.js b/src/migrations/20250130000006-create-invoices.js new file mode 100644 index 0000000..bb04a8b --- /dev/null +++ b/src/migrations/20250130000006-create-invoices.js @@ -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'); + } +}; diff --git a/src/migrations/20250130000007-create-orders.js b/src/migrations/20250130000007-create-orders.js new file mode 100644 index 0000000..c3b0a46 --- /dev/null +++ b/src/migrations/20250130000007-create-orders.js @@ -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'); + } +}; diff --git a/src/models/AssetDownload.js b/src/models/AssetDownload.js new file mode 100644 index 0000000..fd598a9 --- /dev/null +++ b/src/models/AssetDownload.js @@ -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; +}; diff --git a/src/models/AuditLog.js b/src/models/AuditLog.js new file mode 100644 index 0000000..7e4f539 --- /dev/null +++ b/src/models/AuditLog.js @@ -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; +}; diff --git a/src/models/Certificate.js b/src/models/Certificate.js new file mode 100644 index 0000000..bbe2b9e --- /dev/null +++ b/src/models/Certificate.js @@ -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; +}; diff --git a/src/models/Commission.js b/src/models/Commission.js new file mode 100644 index 0000000..da3f8dc --- /dev/null +++ b/src/models/Commission.js @@ -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; +}; diff --git a/src/models/Course.js b/src/models/Course.js new file mode 100644 index 0000000..5cd7d56 --- /dev/null +++ b/src/models/Course.js @@ -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; +}; diff --git a/src/models/CourseEnrollment.js b/src/models/CourseEnrollment.js new file mode 100644 index 0000000..5839d48 --- /dev/null +++ b/src/models/CourseEnrollment.js @@ -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; +}; diff --git a/src/models/Customer.js b/src/models/Customer.js new file mode 100644 index 0000000..bc740de --- /dev/null +++ b/src/models/Customer.js @@ -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; +}; diff --git a/src/models/CustomerService.js b/src/models/CustomerService.js new file mode 100644 index 0000000..dc987a3 --- /dev/null +++ b/src/models/CustomerService.js @@ -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; +}; diff --git a/src/models/Instance.js b/src/models/Instance.js new file mode 100644 index 0000000..8701d1f --- /dev/null +++ b/src/models/Instance.js @@ -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; +}; diff --git a/src/models/InstanceEvent.js b/src/models/InstanceEvent.js new file mode 100644 index 0000000..4ba57cf --- /dev/null +++ b/src/models/InstanceEvent.js @@ -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; +}; diff --git a/src/models/InstanceSnapshot.js b/src/models/InstanceSnapshot.js new file mode 100644 index 0000000..6b0263c --- /dev/null +++ b/src/models/InstanceSnapshot.js @@ -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; +}; diff --git a/src/models/Invoice.js b/src/models/Invoice.js new file mode 100644 index 0000000..13a26a2 --- /dev/null +++ b/src/models/Invoice.js @@ -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; +}; diff --git a/src/models/InvoiceItem.js b/src/models/InvoiceItem.js new file mode 100644 index 0000000..5f06d02 --- /dev/null +++ b/src/models/InvoiceItem.js @@ -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; +}; diff --git a/src/models/KnowledgeArticle.js b/src/models/KnowledgeArticle.js new file mode 100644 index 0000000..8a8231c --- /dev/null +++ b/src/models/KnowledgeArticle.js @@ -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; +}; diff --git a/src/models/LegalAcceptance.js b/src/models/LegalAcceptance.js new file mode 100644 index 0000000..acc97ca --- /dev/null +++ b/src/models/LegalAcceptance.js @@ -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; +}; diff --git a/src/models/LegalDocument.js b/src/models/LegalDocument.js new file mode 100644 index 0000000..0a7da6e --- /dev/null +++ b/src/models/LegalDocument.js @@ -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; +}; diff --git a/src/models/MarketingAsset.js b/src/models/MarketingAsset.js new file mode 100644 index 0000000..6097d63 --- /dev/null +++ b/src/models/MarketingAsset.js @@ -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; +}; diff --git a/src/models/Order.js b/src/models/Order.js new file mode 100644 index 0000000..62b3c07 --- /dev/null +++ b/src/models/Order.js @@ -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; +}; diff --git a/src/models/OrderItem.js b/src/models/OrderItem.js new file mode 100644 index 0000000..6c05eeb --- /dev/null +++ b/src/models/OrderItem.js @@ -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; +}; diff --git a/src/models/Product.js b/src/models/Product.js new file mode 100644 index 0000000..14b2dca --- /dev/null +++ b/src/models/Product.js @@ -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; +}; diff --git a/src/models/Reseller.js b/src/models/Reseller.js new file mode 100644 index 0000000..0e8922d --- /dev/null +++ b/src/models/Reseller.js @@ -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; +}; diff --git a/src/models/ResellerPricing.js b/src/models/ResellerPricing.js new file mode 100644 index 0000000..00c2484 --- /dev/null +++ b/src/models/ResellerPricing.js @@ -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; +}; diff --git a/src/models/ServiceAlert.js b/src/models/ServiceAlert.js new file mode 100644 index 0000000..c326a34 --- /dev/null +++ b/src/models/ServiceAlert.js @@ -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; +}; diff --git a/src/models/UsageRecord.js b/src/models/UsageRecord.js new file mode 100644 index 0000000..36b0fd3 --- /dev/null +++ b/src/models/UsageRecord.js @@ -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; +}; diff --git a/src/models/User.js b/src/models/User.js new file mode 100644 index 0000000..c030945 --- /dev/null +++ b/src/models/User.js @@ -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; +}; diff --git a/src/models/UserSession.js b/src/models/UserSession.js new file mode 100644 index 0000000..2bb4451 --- /dev/null +++ b/src/models/UserSession.js @@ -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; +}; diff --git a/src/models/Wallet.js b/src/models/Wallet.js new file mode 100644 index 0000000..f05e827 --- /dev/null +++ b/src/models/Wallet.js @@ -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; +}; diff --git a/src/models/WalletTransaction.js b/src/models/WalletTransaction.js new file mode 100644 index 0000000..ba5c342 --- /dev/null +++ b/src/models/WalletTransaction.js @@ -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; +}; diff --git a/src/models/index.js b/src/models/index.js new file mode 100644 index 0000000..fd9a921 --- /dev/null +++ b/src/models/index.js @@ -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; diff --git a/src/routes/admin.js b/src/routes/admin.js new file mode 100644 index 0000000..42f9675 --- /dev/null +++ b/src/routes/admin.js @@ -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; diff --git a/src/routes/auth.js b/src/routes/auth.js new file mode 100644 index 0000000..c7de578 --- /dev/null +++ b/src/routes/auth.js @@ -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; diff --git a/src/routes/billing.js b/src/routes/billing.js new file mode 100644 index 0000000..6a768de --- /dev/null +++ b/src/routes/billing.js @@ -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; diff --git a/src/routes/customers.js b/src/routes/customers.js new file mode 100644 index 0000000..0e907e8 --- /dev/null +++ b/src/routes/customers.js @@ -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; diff --git a/src/routes/dashboard.js b/src/routes/dashboard.js new file mode 100644 index 0000000..ef8788b --- /dev/null +++ b/src/routes/dashboard.js @@ -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; diff --git a/src/routes/legal.js b/src/routes/legal.js new file mode 100644 index 0000000..56816a0 --- /dev/null +++ b/src/routes/legal.js @@ -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; diff --git a/src/routes/marketing.js b/src/routes/marketing.js new file mode 100644 index 0000000..d20ec29 --- /dev/null +++ b/src/routes/marketing.js @@ -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; diff --git a/src/routes/orders.js b/src/routes/orders.js new file mode 100644 index 0000000..8e8a8a2 --- /dev/null +++ b/src/routes/orders.js @@ -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; diff --git a/src/routes/products.js b/src/routes/products.js new file mode 100644 index 0000000..119fdc0 --- /dev/null +++ b/src/routes/products.js @@ -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; diff --git a/src/routes/provisioning.js b/src/routes/provisioning.js new file mode 100644 index 0000000..f4f22e7 --- /dev/null +++ b/src/routes/provisioning.js @@ -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; diff --git a/src/routes/reports.js b/src/routes/reports.js new file mode 100644 index 0000000..fbcfbab --- /dev/null +++ b/src/routes/reports.js @@ -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; diff --git a/src/routes/resellers.js b/src/routes/resellers.js new file mode 100644 index 0000000..ac3c8de --- /dev/null +++ b/src/routes/resellers.js @@ -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; diff --git a/src/routes/users.js b/src/routes/users.js new file mode 100644 index 0000000..ba5f79f --- /dev/null +++ b/src/routes/users.js @@ -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; diff --git a/src/scripts/createDatabase.js b/src/scripts/createDatabase.js new file mode 100644 index 0000000..48bc55b --- /dev/null +++ b/src/scripts/createDatabase.js @@ -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;