Welcome to the TruLern LMS documentation. TruLern is a modern, Enterprise-grade MERN Stack Learning Management System (LMS) built on MongoDB, Express.js, React, and Node.js.
Unlike traditional CMS-based solutions, TruLern provides a high-performance, Single Page Application (SPA) experience. It is architected for maximum scalability and modular customization, separating the robust RESTful backend from a state-of-the-art React frontend.
Before installing TruLern, ensure your environment meets these critical requirements for both the Node.js backend and React frontend:
Follow these steps to install the TruLern MERN stack. The process involves setting up the Backend API and the React Frontend separately.
TruLern requires dependencies for the Node.js backend (root) and the React frontend (client).
A. Backend Dependencies:
cd trulern-lms npm install
B. Frontend Dependencies:
cd client npm install
Rename the .env.example file in the root directory to .env
and configure your database and keys.
# Database Configuration MONGO_URI=mongodb://localhost:27017/trulern_db # JWT Authentication Secret (Change this!) JWT_SECRET=your_super_secret_key_123 # Payment Gateway Keys (Optional for Dev) STRIPE_SECRET_KEY=sk_test_... PAYPAL_CLIENT_ID=... RAZORPAY_KEY_ID=...
To populate your database with the default Admin account, demo courses, and instructors, run the seeder script from the root folder:
node backend/install-demo-data.js
Default Admin Login: admin@trulern.com / password123
You need to run two terminals simultaneously (or use a tool like Concurrently).
Terminal 1 (Backend API - Port 5000):
# From root folder node backend/server.js
Terminal 2 (React Frontend - Port 5173):
# From client folder cd client npm run dev
Access the app at: http://localhost:5173
When you are ready to deploy to a live server (AWS/DigitalOcean), you must compile the React app into static HTML/CSS/JS files.
cd client npm run build
This creates a dist folder inside client/. These are the files you will
serve via Nginx or Apache.
Use this configuration to serve the React build files and proxy API requests to your Node.js server.
This configuration ensures user uploads are served correctly from the public folder.
server {
listen 80;
server_name yourdomain.com;
# 1. Serve React Production Build
location / {
root /var/www/trulern-lms/client/dist;
index index.html;
try_files $uri $uri/ /index.html;
}
# 2. Proxy API Requests to Node.js
location /api {
proxy_pass http://localhost:5000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
# 3. Serve User Uploads (Images/PDFs)
# Direct access to the folder where Multer saves files
location /uploads {
alias /var/www/trulern-lms/client/public/uploads;
autoindex off;
}
}
Get the TruLern MERN Stack up and running in under 5 minutes.
Extract the downloaded zip file to your project location.
unzip trulern-lms.zip -d /your/project/path
You must install packages for both the API server and the React client.
# 1. Install Backend cd trulern-lms npm install # 2. Install Frontend cd client npm install
Create a .env file in the root directory:
PORT=5000 MONGO_URI=mongodb://localhost:27017/trulern JWT_SECRET=any_secret_key_for_dev NODE_ENV=development
You need to run the Backend and Frontend simultaneously.
node backend/server.js
cd client npm run dev
Access the App: http://localhost:5173
1. Open your browser to http://localhost:5173
2. Click "Become an Instructor" or navigate to /instructor-register.
3. Register your account. You will be automatically redirected to the Instructor Dashboard.
Populate your LMS with 10 complete sample courses to explore all features immediately.
Before importing demo data, ensure:
Visit http://localhost:5173/instructor-register and create your account.
Open your terminal in the project root and run:
cd backend node install-demo-data.js
You should see output indicating success:
MongoDB Connected... Found 10 course files to import... Imported: Full Stack Web Development Bootcamp Imported: Data Science with Python ... Data Import Process Complete.
Complete courses with lessons, quizzes, and metadata.
Vimeo-integrated video content with previews.
Rich text articles and PDF resources.
Interactive assessments with multiple question types.
# Run from 'backend' directory node install-demo-data.js
# Removes all imported courses node install-demo-data.js -d
After import, verify the data by visiting these pages in your React app:
| Page | React Route | What to Check |
|---|---|---|
| Explore Courses | /explore-courses |
Should display 10 sample courses with working filters. |
| Course Details | /course-details/:id |
Click any course card; content, curriculum, and instructor info should load. |
| Instructor Dashboard | /instructor/dashboard |
Your dashboard should now show 10 active courses and student stats. |
| Cart / Checkout | /checkout |
Add a course to cart and verify the payment flow works. |
node install-demo-data.js -d
before deploying to a live production server.
backend/seed-data/ and re-run the importer.
TruLern LMS is built on a **Client-Server Architecture**. The codebase is split into two distinct applications: the **Node.js Backend API** and the **React Frontend Client**.
Located in /backend, this handles database connections, authentication, business logic,
and file processing.
Located in /client, this is a Vite-powered Single Page Application (SPA).
TruLern LMS requires a properly configured environment file to handle database connections, payments, and emails. You must create this file in the root directory.
.env file contains
sensitive API keys and passwords. Never commit this file to version control (Git).
Create a file named .env in the project root and paste the following configuration.
Update the values with your specific credentials.
# --- SERVER CONFIGURATION --- PORT=5000 # Database Connection String (MongoDB Atlas or Local) MONGO_URI=mongodb+srv://username:password@cluster.mongodb.net/trulern-db # --- SECURITY --- # Random string for signing JSON Web Tokens JWT_SECRET=your_super_secret_random_string_here # --- STRIPE PAYMENTS --- # Publishable Key (Safe for frontend) STRIPE_PUBLISHABLE_KEY=pk_test_... # Secret Key (Keep private!) STRIPE_SECRET_KEY=sk_test_... # --- PAYPAL PAYMENTS --- PAYPAL_CLIENT_ID=... PAYPAL_CLIENT_SECRET=... PAYPAL_MODE=sandbox # Change to 'live' for production # --- RAZORPAY PAYMENTS --- RAZORPAY_KEY_ID=rzp_test_... RAZORPAY_KEY_SECRET=... # --- EMAIL CONFIGURATION (SMTP) --- # Settings for sending password resets and announcements EMAIL_HOST=mail.yourdomain.com EMAIL_PORT=465 EMAIL_USER=admin@yourdomain.com EMAIL_PASS=your_email_password # --- DEMO GUARD (Optional) --- # Protects critical actions (delete/update) in demo mode IS_DEMO_SITE=true DEMO_ADMIN_EMAIL=admin@trulern.com DEMO_STUDENT_EMAIL=student@trulern.com
For the React Frontend to access payment gateways, you must also create a configuration file in the
client/ directory.
VITE_ to be exposed to the browser.
# Base URL for the Backend API VITE_API_BASE_URL=http://localhost:5000/api # Public Keys for Payment Gateways VITE_STRIPE_PUBLISHABLE_KEY=pk_test_... VITE_RAZORPAY_KEY_ID=rzp_test_... VITE_PAYPAL_CLIENT_ID=...
TruLern uses Mongoose to connect to a MongoDB database. You can use either a local installation or a cloud instance (MongoDB Atlas).
backend/server.js. You generally do not need to
touch this file; just update your .env configuration.
This is the easiest way to get started and is required for the app to be accessible live.
mongodb+srv://username:password@cluster0.mongodb.net/trulern?retryWrites=true&w=majority
If you prefer to develop offline, make sure MongoDB Community Server is installed.
mongodb://localhost:27017/trulern
Once you have your connection string, open your .env file in the root
directory and paste it into the MONGO_URI variable.
# .env file (Root Directory) MONGO_URI=mongodb+srv://admin:mysecurepassword123@cluster0.mongodb.net/trulern
@, $, :, or !,
you must URL Encode them. p@ssword, write it as p%40ssword.
TruLern comes pre-integrated with Stripe, PayPal, and Razorpay. You can use all three simultaneously or pick just one.
backend/.env file. Public/Publishable keys go in the client/.env file.
Stripe is the default gateway for Credit Cards, Apple Pay, and Google Pay.
Step A: Backend Setup (Root Directory)Add your keys to .env in the project root:
STRIPE_PUBLISHABLE_KEY=pk_test_... STRIPE_SECRET_KEY=sk_test_...Step B: Frontend Setup (Client Directory)
Add your Publishable key to client/.env so React can initialize the Stripe Elements UI:
VITE_STRIPE_PUBLISHABLE_KEY=pk_test_...
PayPal handles payments via standard checkout or credit card (depending on account type).
Step A: Backend SetupAdd your credentials to the root .env file:
PAYPAL_CLIENT_ID=... PAYPAL_CLIENT_SECRET=... PAYPAL_MODE=sandbox # Change to 'live' for productionStep B: Frontend Setup
The React PayPal SDK requires your Client ID to render the buttons:
VITE_PAYPAL_CLIENT_ID=...
Razorpay is essential for UPI, Netbanking, and Wallets (Indian Subcontinent).
Step A: Backend SetupAdd your keys to the root .env file:
RAZORPAY_KEY_ID=rzp_test_... RAZORPAY_KEY_SECRET=...Step B: Frontend Setup
Add your Key ID to the client configuration:
VITE_RAZORPAY_KEY_ID=rzp_test_...
To remove a payment method from the checkout page, you need to modify the React component.
1. Open client/src/pages/ecommerce/CheckoutPage.jsx.
2. Locate the payment tabs/buttons section.
3. Comment out or delete the block for the gateway you wish to hide.
{/* <button
className={`payment-tab ${paymentMethod === 'razorpay' ? 'active' : ''}`}
onClick={() => setPaymentMethod('razorpay')}
>
Razorpay / UPI
</button>
*/}
Configure SMTP email services for password resets, instructor announcements, and user notifications using Nodemailer.
TruLern uses a centralized email service located at backend/emailService.js. It relies on standard SMTP settings defined in your
root .env file.
Open the .env file in your root directory and
configure the SMTP settings.
# --- EMAIL CONFIGURATION --- EMAIL_HOST=smtp.your-provider.com # e.g., smtp.gmail.com EMAIL_PORT=587 # 587 (TLS) or 465 (SSL) EMAIL_USER=support@yourdomain.com # Your email address EMAIL_PASS=your_password_here # Password or App Password
465 forces SSL, while 587 uses
STARTTLS.
Option A: Gmail (Recommended for Testing)
You must use an App Password if 2-Factor Authentication is enabled.
EMAIL_HOST=smtp.gmail.com EMAIL_PORT=587 EMAIL_USER=yourname@gmail.com EMAIL_PASS=xxxx xxxx xxxx xxxx # 16-digit App Password
Option B: SendGrid
EMAIL_HOST=smtp.sendgrid.net EMAIL_PORT=587 EMAIL_USER=apikey # Username is literally "apikey" EMAIL_PASS=SG.xxxxxxxxxxxxxxxxx # Your API Key
Option C: Mailgun
EMAIL_HOST=smtp.mailgun.org EMAIL_PORT=587 EMAIL_USER=postmaster@mg.yourdomain.com EMAIL_PASS=key-xxxxxxxxxxxxxxxx
Restart your backend server to load the new `.env` values, then test:
http://localhost:5173/login.Common Causes:
apikey.
This usually means the port is blocked by your firewall or ISP.
EMAIL_PORT to 465 (SSL).EMAIL_PORT to 587 (TLS).The HTML email templates are located inside backend/emailService.js.
sendPasswordResetEmail function (approx
line 60).sendAnnouncementEmail function (approx
line 20).TruLern LMS uses Multer for secure file handling. Files are stored locally in the frontend's public directory, making them immediately accessible via URL.
client/public/uploads/.
/uploads directory correctly (see Installation section).
The file upload process follows this secure flow:
┌─────────────────────────────────────────────────────────────────────────────┐
│ TRULERN FILE UPLOAD ARCHITECTURE │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ CLIENT SIDE (React) SERVER SIDE (Node.js) │
│ ┌─────────────────┐ ┌─────────────────────────────────────────┐ │
│ │ │ (1) │ │ │
│ │ Axios Request │─────────▶│ Express.js API Route │ │
│ │ • multipart/ │ POST │ • Auth Middleware │ │
│ │ form-data │ with │ • Multer Middleware │ │
│ │ • File Object │ JWT │ │ │
│ └─────────────────┘ Token └─────────────────┬───────────────────────┘ │
│ │ │
│ │ (2) │
│ ▼ │
│ ┌─────────────────┐ │
│ │ MULTER HANDLER │ │
│ │ • File Filter │ │
│ │ • Size Limit │ │
│ │ • Destination │ │
│ │ • Filename │ │
│ └─────────────────┘ │
│ │ │
│ │ (3) │
│ ▼ │
│ ┌─────────────────┐ │
│ │ DISK STORAGE │ │
│ │ client/public/ │ │
│ │ ├─ avatars/ │ │
│ │ ├─ videos/ │ │
│ │ ├─ pdfs/ │ │
│ │ └─ logos/ │ │
│ └─────────────────┘ │
│ │ │
│ │ (4) │
│ ▼ │
│ ┌─────────────────┐ │
│ │ MONGODB │ │
│ │ • Save file │ │
│ │ path string │ │
│ │ • Update │ │
│ │ document │ │
│ └─────────────────┘ │
│ │ │
│ │ (5) │
│ ▼ │
│ ┌─────────────────┐ │
│ │ JSON RESPONSE │ │
│ │ { │ │
│ │ success: true│ │
│ │ filePath: │ │
│ │ "uploads/..."│ │
│ │ } │ │
│ └─────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
The upload logic is located in backend/server.js. We use separate Multer instances for
different file types to enforce specific size limits.
// Example: Course Thumbnail Uploader
const thumbnailUpload = multer({
storage: multer.diskStorage({
destination: (req, file, cb) => {
// Points to client/public/uploads/thumbnails
const uploadPath = path.join(__dirname, "../client/public/uploads/thumbnails");
fs.mkdirSync(uploadPath, { recursive: true });
cb(null, uploadPath);
},
filename: (req, file, cb) => {
// Unique filename: Timestamp + Original Name
cb(null, `${Date.now()}-${file.originalname.replace(/\s+/g, "_")}`);
},
}),
limits: { fileSize: 5 * 1024 * 1024 } // 5MB Limit
});
In React, we use FormData to send files to the API. Here is how to handle file uploads
in a component.
import axios from 'axios';
const handleFileUpload = async (event) => {
const file = event.target.files[0];
const formData = new FormData();
formData.append('thumbnail', file); // Field name must match backend
try {
const res = await axios.put(
'http://localhost:5000/api/courses/123',
formData,
{
headers: {
'Content-Type': 'multipart/form-data',
'x-auth-token': localStorage.getItem('token') // Auth Header
}
}
);
console.log('Uploaded:', res.data.course.thumbnail);
} catch (err) {
console.error('Upload failed', err);
}
};
| Content Type | Endpoint | Method | Field Name |
|---|---|---|---|
| User Avatar | /api/user/avatar |
POST | avatar |
| Course Thumbnail | /api/instructor/courses |
POST | thumbnail |
| Lesson Video | /api/courses/:id/episodes/:id/lessons |
POST | videoFile |
| PDF Attachment | /api/courses/:id/episodes/:id/lessons |
POST | exerciseFiles |
If the server crashes when trying to save a file, it likely doesn't have write permissions
for the client/public/uploads folder.
# Linux/Mac Solution: chmod -R 755 client/public/uploads
Multer has strict size limits defined in server.js:
To increase these, edit the limits: { fileSize: ... } line in
backend/server.js.
The central hub for instructors to track performance and manage students. Upon login, the dashboard
automatically fetches real-time data from the API using React Hooks
(useEffect).
Calculates total revenue from paid course sales. Data is fetched securely
via /api/instructor/dashboard.
The total count of all courses created, regardless of status (Published, Pending, or Draft).
The number of unique students enrolled across all your courses. Duplicate enrollments are filtered out by the backend.
Located in the "Student Management" tab, this table lists all enrolled students dynamically. Instructors can:
This page allows instructors to manage their public-facing profile, which appears on course landing
pages. In the React app, this is handled by the InstructorProfile.jsx component.
PUT /api/user/profile. Images are uploaded via separate POST endpoints to
ensure faster performance.
POST /api/user/avatarPOST /api/user/coverclient/public/uploads/avatars/ and
client/public/uploads/covers/.
A comprehensive list of all courses created by you. The React frontend filters these client-side, allowing for instant tab switching without reloading the page.
| Tab | Status Description | Actions |
|---|---|---|
| Published | Live Visible to all students and available for purchase/enrollment. | Edit (Opens /instructor/edit-course/:id)
|
| Pending | Hidden Completed courses waiting to be published. Not visible to students. | Edit |
| Draft | In Progress Incomplete courses that are still being built. | Edit |
Instructors can move courses between these states at any time using the Course Builder settings tab.
Broadcast updates to enrolled students via email using the built-in communication tool.
/api/announcements endpoint. The backend
automatically looks up all students enrolled in the target course and queues emails via
emailService.js.
client/public/uploads/announcements/ directory.
// Inside InstructorAnnouncements.jsx
const filteredAnnouncements = announcements.filter((item) => {
if (selectedCourse === 'all') return true;
return item.course && item.course._id === selectedCourse;
});
This dashboard provides visibility into student performance across all quizzes. Data is aggregated
from the QuizResult collection via the /api/instructor/quiz-attempts
endpoint.
passingGrade defined in the quiz settings./lesson/:courseId/result). This page reconstructs the student's attempt, highlighting
correct answers, incorrect choices, and points earned per question.populate method is
used to fetch the Student's name and Course title in a single request, ensuring the table remains
performant even with hundreds of attempts.
A central repository for supplementary learning materials (PDFs, eBooks, Templates) that instructors can manage. This allows you to offer downloadable value-adds to your students.
FormData object to bundle the Title, Category, Thumbnail, and the actual Resource File.
POST /api/resources endpoint utilizes a specialized
Multer field handler to save the thumbnail to
uploads/resources/thumbnails/ and the PDF/Zip to uploads/resources/pdfs/.
/uploads path in a
new browser tab.DELETE /api/resources/:id, which removes the
database record and triggers fs.unlink to delete the physical files from the
server.
// React implementation snippet (InstructorResources.jsx)
const formData = new FormData();
formData.append('title', data.title);
formData.append('thumbnail', thumbnailFile); // Image file
formData.append('pdfFile', resourceFile); // PDF/Zip file
await axios.post('/api/resources', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
The centralized configuration hub for instructors. This page allows for comprehensive management of public profile details, security, and social presence. In the React application, this page utilizes State Management to provide a seamless, multi-tab experience without page refreshes.
Instructors can fully customize their public-facing identity. Visual assets uploaded here update
immediately across the platform via the AuthContext.
POST /api/user/cover. This banner
represents your brand on the instructor profile page (Recommended: 1920x400px).POST /api/user/avatar. The system
handles the upload through Multer and stores the reference path in the User document.PUT /api/user/profile.A dedicated security tab for credential management. To prevent unauthorized changes, the backend
PUT /api/user/password route validates the Current Password using
bcrypt.compare() before allowing a password reset.
Configure links to external platforms. These are stored in a nested social object within
the MongoDB User schema and appear as icons on your public profile.
multipart/form-data. The React frontend implements URL.createObjectURL(file)
to provide an instant UI preview while the upload is still processing in the
background, ensuring a high-performance "Pro" feel.
The student dashboard serves as the central command center for learners. It provides an immediate
snapshot of their learning journey with real-time statistics fetched dynamically via the
/api/student/dashboard endpoint.
The total number of courses the student has purchased or enrolled in. This
data is derived from the user's enrolledCourses array in MongoDB.
Tracks courses currently in progress. A course is considered active if it is present in the enrollment list but has not yet reached a 100% progress state.
Courses where the student has finished all lessons and quizzes. Once 100%
progress is reached, the status is updated to completed.
// GET /api/student/dashboard
{
"success": true,
"data": {
"enrolledCourses": 5,
"activeCourses": 3,
"completedCourses": 2
}
}
The enrolled courses view provides a tabbed interface for students to organize their learning by status. React state handles the switching between All, Active, and Completed views instantly without page reloads.
/course/:id./api/certificate/:id/download. The backend generates a
personalized PDF on the fly using the pdf-lib library and serves it as a buffer for the
browser to download.
// Logic within the StudentCourseCard component
const progressPercentage = Math.round((completedLessons / totalLessons) * 100);
// Rendered as:
// <div className="progress-bar" style={{ width: `${progressPercentage}%` }} />
A personalized collection of courses saved for future reference. The wishlist functionality is integrated globally across the application, appearing on course cards, category filters, and individual course landing pages.
POST request
to the /api/user/wishlist/toggle endpoint, passing the target courseId.
wishlist array within the MongoDB User document by adding or removing the course
reference.
// React implementation snippet for toggling wishlist items
const handleToggleWishlist = async (id) => {
try {
const { data } = await axios.post('/api/user/wishlist/toggle', { courseId: id });
if (data.success) {
// Synchronize the UI state with the updated database value
setIsBookmarked(data.inWishlist);
}
} catch (error) {
console.error('Wishlist toggle failed:', error);
}
};
A history of all reviews and ratings submitted by the student. This provides transparency and allows learners to track and manage their feedback across all enrolled courses within the application.
Review collection.GET /api/student/reviews. The backend controller uses Mongoose
.populate('courseId') to fetch the course details associated with each review in a single
transaction.
// React logic to render stars based on the numeric rating
const renderStars = (rating) => {
return Array.from({ length: 5 }, (_, i) => (
<i key={i} className={i < rating ? "feather-star active" : "feather-star"} />
));
};
A detailed log of every assessment taken by the student. This helps learners track their improvement over time.
| Column | Description |
|---|---|
| Quiz Info | Date taken and Quiz Title. |
| Stats | Total Questions, Total Marks, Correct Answers. |
| Result | Pass or Fail based on the passing grade. |
| Action | View Details: Opens the full result breakdown. |
A financial record of all course purchases. Useful for tracking expenses and verifying enrollment status.
The "God Mode" hub for platform owners. The Admin Dashboard provides high-level oversight of all students, instructors, and communications across the entire TruLern ecosystem.
Monitor global metrics including total platform revenue, total active
students, and instructor performance via /api/admin/stats.
Directly manage user access levels and verify instructor applications to maintain quality across the marketplace.
The central registry of every account on the platform. The Admin can search, filter, and manage permissions for both Students and Instructors from this unified interface.
GET /api/admin/users endpoint which accepts role and search
query parameters. Authentication token is automatically attached via the axios interceptor configured
in your React service layer.
A high-level debugging tool that allows the Super Admin to "become" any user. This is critical for reproducing bugs reported by specific students or instructors.
ShadowModeAlert
component.
// POST /api/admin/shadow/:userId
// Returns a temporary JWT for the target user
const enterShadowMode = async (userId) => {
try {
const response = await api.post(`/admin/shadow/${userId}`);
const { token } = response.data;
// AuthContext handles token swap and maintains admin backup
authContext.enterShadowMode(token, originalUser);
} catch (error) {
console.error('Shadow mode failed:', error);
}
};
api) which automatically attaches the authentication token.
Shadow state is managed through AuthContext, not localStorage.
The Super Admin has a birds-eye view of every course created on the platform. This is essential for quality control and ensuring all content meets platform standards before going live.
Pending status and
move them to Published to make them live for students.
// GET /api/admin/courses
// Using configured axios instance with automatic auth header
const fetchAllCourses = async () => {
try {
const response = await api.get('/admin/courses');
return response.data.courses;
} catch (error) {
console.error('Failed to fetch courses:', error);
}
};
// Response JSON
{
"success": true,
"courses": [
{
"_id": "60d5ec182f8fb810b4f274a1",
"title": "Advanced Web Development",
"instructor": "Martha Cousins",
"status": "published"
}
]
}
api) which automatically attaches the JWT token via
interceptor. No manual header management required.
Unlike instructors, the Admin has the power to send platform-wide announcements that reach every registered user on the site.
everyone, all_students, or all_instructors.
// Target keys for global announcements
const TARGETS = {
EVERYONE: "everyone",
STUDENTS: "all_students",
INSTRUCTORS: "all_instructors"
};
// Send broadcast announcement
const sendBroadcast = async (target, subject, message, attachments = []) => {
try {
const formData = new FormData();
formData.append('target', target);
formData.append('subject', subject);
formData.append('message', message);
// Append attachment files if any
attachments.forEach((file, index) => {
formData.append(`attachment_${index}`, file);
});
const response = await api.post('/admin/broadcast', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
return response.data;
} catch (error) {
console.error('Broadcast failed:', error);
}
};
// Example: Send platform-wide maintenance alert
sendBroadcast(
TARGETS.EVERYONE,
"Scheduled Maintenance",
"The platform will be unavailable on Sunday, 2:00 AM - 4:00 AM EST."
);
client/public/uploads/announcements/.
A comprehensive financial ledger of every transaction occurring on the TruLern LMS. This section is vital for tracking platform revenue and managing student enrollment records.
// Fetch platform-wide orders with optional filters
const fetchOrders = async (filters = {}) => {
try {
const response = await api.get('/admin/orders', {
params: {
status: filters.status, // success, pending, cancelled
startDate: filters.startDate,
endDate: filters.endDate,
paymentMethod: filters.paymentMethod // stripe, paypal, razorpay
}
});
return response.data.orders;
} catch (error) {
console.error('Failed to fetch orders:', error);
}
};
// Fetch single order details for modal view
const fetchOrderDetails = async (orderId) => {
try {
const response = await api.get(`/admin/orders/${orderId}`);
return response.data.order;
} catch (error) {
console.error('Failed to fetch order details:', error);
}
};
// Response format
{
"success": true,
"orders": [
{
"_id": "60d5ec182f8fb810b4f274a1",
"orderId": "#8A2B3C",
"student": {
"name": "John Doe",
"email": "john@example.com"
},
"items": [
{
"courseId": "60d5ec182f8fb810b4f274b2",
"title": "Advanced Web Development",
"price": 49.99
}
],
"total": 49.99,
"paymentMethod": "stripe",
"status": "success",
"createdAt": "2026-02-13T10:30:00.000Z"
}
]
}
A centralized moderation queue where the Admin can monitor, search, and force-delete reviews from any course on the platform.
| Review Type | Visibility | Moderator Power |
|---|---|---|
| Student Review | Standard | Admins can delete spam or unfair reviews directly. |
| Manual Testimonial | Instructor Entry | Monitor instructor-added testimonials for authenticity. |
// Fetch all reviews with search and filter
const fetchAllReviews = async (searchTerm = '', filterType = 'all') => {
try {
const response = await api.get('/admin/reviews', {
params: {
search: searchTerm,
type: filterType // 'all', 'student', 'testimonial'
}
});
return response.data.reviews;
} catch (error) {
console.error('Failed to fetch reviews:', error);
}
};
// Delete review (moderation)
const deleteReview = async (reviewId) => {
try {
await api.delete(`/admin/reviews/${reviewId}`);
// UI updates reactively via state management
} catch (error) {
console.error('Failed to delete review:', error);
}
};
// React component search implementation
const ReviewModerationPanel = () => {
const [reviews, setReviews] = useState([]);
const [searchTerm, setSearchTerm] = useState('');
// Memoized filtered results - runs only when dependencies change
const filteredReviews = useMemo(() => {
if (!searchTerm) return reviews;
const term = searchTerm.toLowerCase();
return reviews.filter(review =>
review.user.name.toLowerCase().includes(term) ||
review.course.title.toLowerCase().includes(term)
);
}, [reviews, searchTerm]);
// ... rest of component
};
useMemo for performance, preventing unnecessary re-renders. The review
list updates instantly as the admin types, with zero UI lag.
Manage the Super Admin's personal profile and security credentials. This follows the same secure architecture as the instructor settings.
URL.createObjectURL()
before upload. Files are sent as multipart/form-data to /api/user/avatar.
/api/user/cover for profile
banner image.
// PUT /api/user/profile
const updateProfile = async (profileData) => {
try {
const response = await api.put('/user/profile', profileData);
// AuthContext automatically updates global user state
authContext.updateUser(response.data.user);
return response.data;
} catch (error) {
console.error('Profile update failed:', error);
}
};
// Request payload
{
"firstName": "Super",
"lastName": "Admin",
"occupation": "Platform Owner",
"bio": "Managing the TruLern ecosystem.",
"phone": "+1 555 123 4567",
"social": {
"facebook": "https://facebook.com/trulern",
"twitter": "https://twitter.com/trulern",
"linkedin": "https://linkedin.com/company/trulern",
"website": "https://trulern.com",
"github": "https://github.com/trulern"
}
}
// POST /api/user/avatar
const uploadAvatar = async (file) => {
try {
const formData = new FormData();
formData.append('avatar', file);
const response = await api.post('/user/avatar', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
// Update context with new avatar URL
authContext.updateAvatar(response.data.avatarUrl);
return response.data;
} catch (error) {
console.error('Avatar upload failed:', error);
}
};
// React instant preview
const handleFileSelect = (e) => {
const file = e.target.files[0];
const previewUrl = URL.createObjectURL(file);
setAvatarPreview(previewUrl);
uploadAvatar(file);
};
// PUT /api/user/password
const changePassword = async (currentPassword, newPassword) => {
try {
await api.put('/user/password', {
currentPassword,
newPassword
});
toast.success('Password updated successfully');
} catch (error) {
console.error('Password change failed:', error);
toast.error('Current password is incorrect');
}
};
AuthContext, ensuring the admin's avatar and name update instantly
across the entire application without page refresh.
TruLern's Course Builder is a sophisticated, two-phase system that enables instructors to create professional online courses with rich multimedia content, interactive assessments, and automated certificates. Built as a fully responsive React SPA with real-time state management.
React Router navigates from /instructor/create-course → /instructor/edit-course/:courseId with persistent state
Video (Vimeo/YouTube), Rich Text Reading, Quizzes, Assignments, PDF resources
5+ question types with comprehensive settings, automated grading, and attempt tracking
Dynamic PDF generation with instructor branding, student name, and completion date
// Protected routes for course creation and editing
<Route path="/instructor/create-course" element={
<PrivateRoute><CreateCourse /></PrivateRoute>
} />
<Route path="/instructor/edit-course/:courseId" element={
<PrivateRoute><EditCourse /></PrivateRoute>
} />
Navigate to Instructor Dashboard and click the "Create New Course" button. React Router navigates to /instructor/create-course.
Fill in title, description, category, and upload thumbnail via the CreateCourse.jsx component. Form data is sent to POST /api/instructor/courses.
const createCourse = async (formData) => {
try {
const response = await api.post('/instructor/courses', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
// Redirect to edit page with new course ID
navigate(`/instructor/edit-course/${response.data.course._id}`);
} catch (error) {
console.error('Course creation failed:', error);
}
};
The EditCourse.jsx component loads all course data via GET /api/instructor/courses/:courseId and provides a tabbed interface for managing:
All changes are saved individually via PUT/POST requests with optimistic UI updates.
The initial course creation process captures essential metadata and prepares the course
for detailed content building. Implemented in the CreateCourse.jsx component with React Hook Form validation.
| Field | Description | Validation | Required |
|---|---|---|---|
| Course Title | Public display name for the course | Max 100 characters | Yes |
| Course Slug | URL-friendly identifier | Auto-generated from title, unique constraint | Yes |
| About Course | Detailed description (supports rich text via TinyMCE) | HTML formatting allowed | No |
| Thumbnail | Course cover image | 700x430px recommended, ≤5MB | Yes |
| Category | Course classification | Select from predefined categories | Yes |
{
"dimensions": "700x430 pixels (16:9 ratio)",
"formats": ["jpg", "jpeg", "png", "webp"],
"maxSize": "5MB",
"aspectRatio": "16:9",
"colorMode": "RGB",
"optimization": "Compress before upload for faster loading"
}
const CreateCourse = () => {
const navigate = useNavigate();
const [thumbnailPreview, setThumbnailPreview] = useState('');
const [loading, setLoading] = useState(false);
// Slug auto-generation from title
const generateSlug = (title) => {
return title
.toLowerCase()
.replace(/[^\w\s]/g, '')
.replace(/\s+/g, '-');
};
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
const formData = new FormData();
formData.append('title', title);
formData.append('slug', generateSlug(title));
formData.append('description', description);
formData.append('category', category);
formData.append('thumbnail', thumbnailFile);
formData.append('price', '0'); // Default - set in Step 2
formData.append('originalPrice', '0'); // Default - set in Step 2
formData.append('maxStudents', '0'); // Default - set in Step 2
try {
const response = await api.post('/instructor/courses', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
if (response.data.success) {
// React Router navigation to edit page with course ID
navigate(`/instructor/edit-course/${response.data.course._id}`);
}
} catch (error) {
console.error('Course creation failed:', error);
toast.error(error.response?.data?.message || 'Failed to create course');
} finally {
setLoading(false);
}
};
// Instant thumbnail preview
const handleThumbnailChange = (e) => {
const file = e.target.files[0];
if (file) {
setThumbnailFile(file);
setThumbnailPreview(URL.createObjectURL(file));
}
};
return (
<form onSubmit={handleSubmit}>
{/* Form fields */}
</form>
);
};
{
"success": true,
"message": "Course created successfully!",
"course": {
"_id": "67a1b2c3d4e5f67890123456",
"title": "Full Stack Web Development",
"slug": "full-stack-web-development",
"status": "Draft",
"thumbnail": "/uploads/thumbnails/1704038400000-full-stack-web-dev.jpg",
"category": "web-development",
"instructor": {
"_id": "instructor_user_id",
"name": "John Doe"
},
"createdAt": "2024-12-30T10:30:00.000Z",
"updatedAt": "2024-12-30T10:30:00.000Z"
}
}
Error Message: "Course with this slug already exists"
Solution: The slug is auto-generated from title. If a duplicate exists, the system automatically appends a timestamp or unique ID. Manual slug editing is available in course settings.
Error Message: "Course thumbnail is required." or file size/format errors
Solution: Ensure thumbnail meets requirements:
Error Message: "No token provided" or "Access denied"
Solution: This route is protected by PrivateRoute component. Users are redirected to login if not authenticated. The axios interceptor automatically attaches the JWT token to all requests.
URL.createObjectURL()The full course builder provides comprehensive configuration through a tabbed React interface with real-time updates and nested curriculum management. Implemented in EditCourse.jsx with React Router params for course ID.
Basic course information and media assets with preview functionality
Nested drag-and-drop interface for course structure
import React, { useState, useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { useAuth } from '../../context/AuthContext';
// Tab Components
import CourseInfoTab from './tabs/CourseInfoTab';
import CourseSettingsTab from './tabs/CourseSettingsTab';
import PricingTab from './tabs/PricingTab';
import CurriculumTab from './tabs/CurriculumTab';
import CertificateTab from './tabs/CertificateTab';
const EditCourse = () => {
const { courseId } = useParams();
const { user } = useAuth();
const [activeTab, setActiveTab] = useState('info');
const [course, setCourse] = useState(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
// Fetch course data on mount
useEffect(() => {
fetchCourse();
}, [courseId]);
const fetchCourse = async () => {
try {
const token = localStorage.getItem('lmsToken');
const response = await fetch(`/api/courses/${courseId}`, {
headers: { 'x-auth-token': token }
});
const data = await response.json();
if (data.success) {
setCourse(data.course);
}
} catch (err) {
setError('Failed to load course');
} finally {
setLoading(false);
}
};
// Tab navigation
const tabs = [
{ id: 'info', label: 'Course Info', icon: 'feather-info' },
{ id: 'settings', label: 'Settings', icon: 'feather-settings' },
{ id: 'pricing', label: 'Pricing', icon: 'feather-dollar-sign' },
{ id: 'curriculum', label: 'Curriculum', icon: 'feather-book-open' },
{ id: 'certificate', label: 'Certificate', icon: 'feather-award' }
];
if (loading) return Loading course...;
if (!course) return Course not found;
return (
<div className="tru-edit-course-area bg-color-white tru-section-gap">
<div className="container">
{/* Tab Navigation */}
<ul className="nav nav-tabs course-edit-tabs" role="tablist">
{tabs.map(tab => (
<li key={tab.id} className="nav-item" role="presentation">
<button
className={`nav-link ${activeTab === tab.id ? 'active' : ''}`}
onClick={() => setActiveTab(tab.id)}
type="button"
>
<i className={`${tab.icon} me-2`}></i>
{tab.label}
</button>
</li>
))}
</ul>
{/* Tab Content */}
<div className="tab-content mt-4">
{activeTab === 'info' && (
<CourseInfoTab
course={course}
setCourse={setCourse}
setSaving={setSaving}
setError={setError}
/>
)}
{activeTab === 'settings' && (
<CourseSettingsTab
course={course}
setCourse={setCourse}
setSaving={setSaving}
/>
)}
{activeTab === 'pricing' && (
<PricingTab
course={course}
setCourse={setCourse}
setSaving={setSaving}
/>
)}
{activeTab === 'curriculum' && (
<CurriculumTab
courseId={courseId}
episodes={course.episodes || []}
/>
)}
{activeTab === 'certificate' && (
<CertificateTab
course={course}
setCourse={setCourse}
setSaving={setSaving}
/>
)}
</div>
{/* Save Status */}
{saving && (
<div className="alert alert-info mt-3">
<i className="feather-loader spin me-2"></i>
Saving changes...
</div>
)}
{error && (
<div className="alert alert-danger mt-3">
<i className="feather-alert-circle me-2"></i>
{error}
</div>
)}
</div>
</div>
);
};
export default EditCourse;
const CurriculumTab = ({ courseId, episodes }) => {
const [topics, setTopics] = useState(episodes || []);
const [expandedTopic, setExpandedTopic] = useState(null);
// Add new topic
const addTopic = async () => {
const title = prompt('Enter topic title:');
if (!title) return;
try {
const token = localStorage.getItem('lmsToken');
const response = await fetch(`/api/courses/${courseId}/episodes`, {
method: 'POST',
headers: {
'x-auth-token': token,
'Content-Type': 'application/json'
},
body: JSON.stringify({ title, summary: '' })
});
const data = await response.json();
if (data.success) {
setTopics([...topics, data.episode]);
}
} catch (err) {
alert('Failed to create topic');
}
};
// Drag and drop reordering
const moveItem = (fromIndex, toIndex, type, parentIndex = null) => {
// Implementation for reordering topics and lessons
// Updates state and syncs with backend
};
return (
<div className="curriculum-builder">
<div className="d-flex justify-content-between mb-3">
<h4>Course Curriculum</h4>
<button
className="tru-btn btn-outline-primary"
onClick={addTopic}
>
<i className="feather-plus me-2"></i>
Add Topic
</button>
</div>
<div className="topics-container">
{topics.map((topic, topicIndex) => (
<TopicItem
key={topic._id}
topic={topic}
topicIndex={topicIndex}
courseId={courseId}
isExpanded={expandedTopic === topic._id}
onToggle={() => setExpandedTopic(
expandedTopic === topic._id ? null : topic._id
)}
onMove={moveItem}
/>
))}
</div>
</div>
);
};
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/courses/:courseId |
Fetch full course data |
| PUT | /api/courses/:courseId |
Update course details (multipart/form-data) |
| POST | /api/courses/:courseId/episodes |
Create new topic |
| PUT | /api/courses/:courseId/episodes/:episodeId |
Update topic |
| DELETE | /api/courses/:courseId/episodes/:episodeId |
Delete topic |
| POST | /api/courses/:courseId/episodes/:episodeId/lessons |
Add lesson to topic (supports file upload) |
| POST | /api/courses/:courseId/episodes/:episodeId/quizzes |
Add quiz to topic |
| PUT | /api/courses/:courseId/reorder |
Update curriculum order (bulk update) |
const CourseSchema = new mongoose.Schema({
title: { type: String, required: true },
slug: { type: String, required: true, unique: true },
description: String,
instructor: { type: mongoose.Schema.Types.ObjectId, ref: 'User' },
thumbnail: String,
courseLogo: String,
previewVideo: String,
courseIntro: String,
price: { type: Number, default: 0 },
originalPrice: { type: Number, default: 0 },
status: {
type: String,
enum: ['Draft', 'Pending', 'Published'],
default: 'Draft'
},
difficultyLevel: {
type: String,
enum: ['Beginner', 'Intermediate', 'Expert', 'All Levels']
},
maxStudents: { type: Number, default: 0 },
isPublic: { type: Boolean, default: false },
isQAEnabled: { type: Boolean, default: true },
isMasterclass: { type: Boolean, default: false },
school: String,
categories: [String],
episodes: [{
title: String,
summary: String,
lessons: [{
title: String,
content: String,
video: String,
attachments: [String],
duration: Number,
isPreview: Boolean
}],
quizzes: [{
title: String,
questions: [{
question: String,
options: [String],
correctAnswer: Number,
explanation: String
}],
passingScore: Number
}],
assignments: [{
title: String,
description: String,
dueDays: Number
}]
}],
certificateTemplate: {
type: String,
enum: ['landscape', 'portrait']
},
certificateOrientation: String,
includesCertificate: { type: Boolean, default: true },
signature: String,
createdAt: { type: Date, default: Date.now },
updatedAt: { type: Date, default: Date.now }
});
useParams() hook for course IDURL.createObjectURL()EditCourse component. Tab components receive course data and update functions as props, ensuring single source of truth.
Organize your course content into Topics (Episodes) containing Lessons, Quizzes, and Assignments. Implemented in CurriculumBuilder.jsx component with accordion-based navigation and modal forms for content creation.
Organizational containers with title and summary
1+ per courseVideo or reading content with duration tracking
Unlimited per topicInteractive assessments with multiple questions
Multiple per topic
import React, { useState } from 'react';
const CurriculumBuilder = ({ episodes = [], courseId, onRefresh, onOpenModal }) => {
const [expandedEpisode, setExpandedEpisode] = useState(null);
// Toggle episode accordion
const toggleEpisode = (episodeId) => {
setExpandedEpisode(expandedEpisode === episodeId ? null : episodeId);
};
// Delete an item (lesson, quiz, assignment)
const handleDeleteItem = async (episodeId, itemId, itemType) => {
if (!window.confirm(`Are you sure you want to delete this ${itemType}?`)) {
return;
}
try {
const token = localStorage.getItem('lmsToken');
const pathSegment = itemType === 'quiz' ? 'quizzes' : `${itemType}s`;
const response = await fetch(`/api/courses/${courseId}/episodes/${episodeId}/${pathSegment}/${itemId}`, {
method: 'DELETE',
headers: { 'x-auth-token': token }
});
const result = await response.json();
if (result.success) {
onRefresh();
} else {
alert(`Error: ${result.message}`);
}
} catch (error) {
alert(`An error occurred while deleting the ${itemType}.`);
}
};
// Delete an episode (topic)
const handleDeleteEpisode = async (episodeId) => {
if (!window.confirm('Are you sure you want to delete this topic? All lessons and quizzes within will also be deleted.')) {
return;
}
try {
const token = localStorage.getItem('lmsToken');
const response = await fetch(`/api/courses/${courseId}/episodes/${episodeId}`, {
method: 'DELETE',
headers: { 'x-auth-token': token }
});
const result = await response.json();
if (result.success) {
onRefresh();
}
} catch (error) {
alert('An error occurred while deleting the topic.');
}
};
// Get icon based on item type
const getItemIcon = (itemType, lessonType) => {
if (itemType === 'lesson') {
return lessonType === 'reading' ? 'feather-file-text' : 'feather-play-circle';
}
if (itemType === 'quiz') return 'feather-help-circle';
if (itemType === 'assignment') return 'feather-clipboard';
return 'feather-file';
};
// Render content items (lessons, quizzes)
const renderContentItems = (episode) => {
const items = [
...(episode.lessons || []).map(item => ({ ...item, type: 'lesson' })),
...(episode.quizzes || []).map(item => ({ ...item, type: 'quiz' })),
];
if (items.length === 0) {
return (
<div className="text-center p-3 border rounded bg-light mb-4">
<i className="feather-info text-muted me-2"></i>
<span className="text-muted">No content yet. Add lessons or quizzes below.</span>
</div>
);
}
return items.map((item) => (
<div key={item._id} className="d-flex justify-content-between tru-course-wrape mb-4">
<div className="col-10 inner d-flex align-items-center gap-2">
<i className={getItemIcon(item.type, item.lessonType)}></i>
<h6 className="tru-title mb-0">{item.title}</h6>
{item.exerciseFiles?.length > 0 && (
<i className="feather-paperclip ms-2 text-muted"></i>
)}
{item.duration && (
<span className="badge bg-light text-dark border ms-2">
<i className="feather-clock me-1"></i>
{item.duration}
</span>
)}
</div>
<div className="col-2 inner">
<ul className="tru-list-style-1 tru-course-list d-flex gap-2">
<li>
<i
className="feather-trash delete-item cb-action-icon"
onClick={() => handleDeleteItem(episode._id, item._id, item.type)}
title={`Delete ${item.type}`}
></i>
</li>
<li>
<i
className="feather-edit edit-item cb-action-icon"
onClick={() => {
if (item.type === 'lesson') {
onOpenModal('lesson', {
episodeId: episode._id,
lessonData: item
});
} else if (item.type === 'quiz') {
onOpenModal('quiz', {
episodeId: episode._id,
quizData: item
});
}
}}
title={`Edit ${item.type}`}
></i>
</li>
</ul>
</div>
</div>
));
};
return (
<div className="course-builder-wrapper">
{episodes.length === 0 ? (
<div className="text-center p-5 border rounded bg-light">
<i className="feather-folder text-muted mb-3 cb-empty-icon"></i>
<h5 className="text-muted">No topics yet</h5>
<p className="text-muted">Click "Add New Topic" to start building your course curriculum</p>
</div>
) : (
episodes.map((episode) => (
<div key={episode._id} className="accordion-item card mb--20">
<h2 className="accordion-header card-header tru-course">
<button
className={`accordion-button ${expandedEpisode === episode._id ? '' : 'collapsed'}`}
onClick={() => toggleEpisode(episode._id)}
>
{episode.title}
</button>
<span
className="tru-course-icon tru-course-edit"
onClick={() => onOpenModal('topic', episode)}
title="Edit Topic"
></span>
<span
className="tru-course-icon tru-course-del"
onClick={() => handleDeleteEpisode(episode._id)}
title="Delete Topic"
></span>
</h2>
{expandedEpisode === episode._id && (
<div className="accordion-collapse collapse show">
<div className="accordion-body card-body">
{episode.summary && (
<div className="mb-4">
<h6 className="d-flex align-items-center gap-2">
<i className="feather-info text-primary"></i>
<span>Topic Summary</span>
</h6>
<p className="text-muted ms-4">{episode.summary}</p>
</div>
)}
<div className="mb-4">
<h6 className="d-flex align-items-center gap-2 mb-3">
<i className="feather-list text-primary"></i>
<span>Content ({episode.lessons?.length || 0} lessons, {episode.quizzes?.length || 0} quizzes)</span>
</h6>
{renderContentItems(episode)}
</div>
<div className="border-top pt-4 mt-4">
<div className="d-flex flex-wrap gap-3">
<button
className="tru-btn btn-border hover-icon-reverse tru-sm-btn-2"
onClick={() => onOpenModal('lesson', { episodeId: episode._id })}
>
<span className="icon-reverse-wrapper">
<span className="btn-text">Add Lesson</span>
<span className="btn-icon"><i className="feather-plus-circle"></i></span>
<span className="btn-icon"><i className="feather-plus-circle"></i></span>
</span>
</button>
<button
className="tru-btn btn-border hover-icon-reverse tru-sm-btn-2"
onClick={() => onOpenModal('quiz', { episodeId: episode._id })}
>
<span className="icon-reverse-wrapper">
<span className="btn-text">Add Quiz</span>
<span className="btn-icon"><i className="feather-plus-circle"></i></span>
<span className="btn-icon"><i className="feather-plus-circle"></i></span>
</span>
</button>
</div>
</div>
</div>
</div>
)}
</div>
))
)}
</div>
);
};
export default CurriculumBuilder;
The curriculum uses three modal components for creating and editing content:
| Modal | File | Purpose |
|---|---|---|
| Topic Modal | TopicModal.jsx |
Create and edit topics (episodes) with title and summary |
| Lesson Modal | LessonModal.jsx |
Create and edit lessons with title, content, video URL, attachments, and duration |
| Quiz Modal | QuizModal.jsx |
Create and edit quizzes with questions, options, correct answers, and explanations |
| Type | Icon | Description | Edit Modal |
|---|---|---|---|
| Video Lesson | Vimeo/YouTube/Local video with duration tracking | LessonModal.jsx |
|
| Reading Lesson | Rich text content (lessonType='reading') | LessonModal.jsx |
|
| Quiz | Interactive assessment with multiple questions | QuizModal.jsx |
|
| Assignment | Practical exercises with file submissions (exerciseFiles) | LessonModal.jsx (as lesson type) |
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/courses/:courseId/episodes |
Create new topic |
| PUT | /api/courses/:courseId/episodes/:episodeId |
Update topic |
| DELETE | /api/courses/:courseId/episodes/:episodeId |
Delete topic |
| POST | /api/courses/:courseId/episodes/:episodeId/lessons |
Add lesson to topic |
| PUT | /api/courses/:courseId/episodes/:episodeId/lessons/:lessonId |
Update lesson |
| DELETE | /api/courses/:courseId/episodes/:episodeId/lessons/:lessonId |
Delete lesson |
| POST | /api/courses/:courseId/episodes/:episodeId/quizzes |
Add quiz to topic |
| PUT | /api/courses/:courseId/episodes/:episodeId/quizzes/:quizId |
Update quiz |
| DELETE | /api/courses/:courseId/episodes/:episodeId/quizzes/:quizId |
Delete quiz |
// Modal state
const [modalConfig, setModalConfig] = useState({
type: null, // 'topic', 'lesson', 'quiz'
data: null
});
// Open modal handler
const handleOpenModal = (type, data = null) => {
setModalConfig({ type, data });
};
// Close modal handler
const handleCloseModal = () => {
setModalConfig({ type: null, data: null });
refreshCourse(); // Refresh after modal closes
};
// Render modals
{modalConfig.type === 'topic' && (
<TopicModal
show={true}
onClose={handleCloseModal}
courseId={courseId}
topicData={modalConfig.data}
/>
)}
{modalConfig.type === 'lesson' && (
<LessonModal
show={true}
onClose={handleCloseModal}
courseId={courseId}
episodeId={modalConfig.data?.episodeId}
lessonData={modalConfig.data?.lessonData}
/>
)}
{modalConfig.type === 'quiz' && (
<QuizModal
show={true}
onClose={handleCloseModal}
courseId={courseId}
episodeId={modalConfig.data?.episodeId}
quizData={modalConfig.data?.quizData}
/>
)}
window.confirm()onOpenModal callbackonRefreshCurriculumBuilder is a presentational component. All data fetching and modal state management is handled by the parent EditCourse component.
TruLern supports two types of lessons: Video Lessons (Vimeo/YouTube/Local) and Reading Lessons (rich text articles) with exercise file attachments. Implemented in LessonModal.jsx using React hooks and TinyMCE editor.
| Source | URL Format / Input | Max Size | Player |
|---|---|---|---|
| Vimeo | https://vimeo.com/123456789 |
N/A | Vimeo Embedded Player |
| YouTube | https://youtube.com/watch?v=ABC123 |
N/A | YouTube iFrame API |
| Local Upload | File input (accept="video/*") |
500 MB | HTML5 Video Player |
import React, { useState, useEffect, useRef } from 'react';
import ReactDOM from 'react-dom';
import { Editor } from '@tinymce/tinymce-react';
import Select from 'react-select';
const LessonModal = ({ show, onClose, onSave, courseId, episodeId, initialData = null }) => {
const [lessonType, setLessonType] = useState('video');
const [title, setTitle] = useState('');
const [summary, setSummary] = useState('');
const [isPreview, setIsPreview] = useState(false);
// Video fields
const [videoSource, setVideoSource] = useState('vimeo');
const [videoUrl, setVideoUrl] = useState('');
const [videoFile, setVideoFile] = useState(null);
const [durationHr, setDurationHr] = useState('0');
const [durationMin, setDurationMin] = useState('0');
const [durationSec, setDurationSec] = useState('0');
// Reading fields
const [articleBody, setArticleBody] = useState('');
// Exercise files
const [exerciseFiles, setExerciseFiles] = useState([]);
const [existingExerciseFiles, setExistingExerciseFiles] = useState([]);
const [filesToDelete, setFilesToDelete] = useState([]);
// State
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const fileInputRef = useRef(null);
// TinyMCE configuration
const editorConfig = {
height: 300,
menubar: false,
plugins: 'link lists preview',
toolbar: 'undo redo | formatselect | bold italic | alignleft aligncenter alignright alignjustify | bullist numlist outdent indent | link | preview',
content_style: 'body { font-family:Helvetica,Arial,sans-serif; font-size:14px }',
};
// Options for react-select
const lessonTypeOptions = [
{ value: 'video', label: 'Video Lesson' },
{ value: 'reading', label: 'Reading / Article Lesson' }
];
const videoSourceOptions = [
{ value: 'vimeo', label: 'Vimeo' },
{ value: 'youtube', label: 'YouTube' },
{ value: 'local', label: 'Local Video Upload' }
];
// Initialize form when modal opens
useEffect(() => {
if (show) {
if (initialData) {
// Editing existing lesson
setLessonType(initialData.lessonType || 'video');
setTitle(initialData.title || '');
setSummary(initialData.summary || '');
setIsPreview(initialData.isPreview || false);
// Video fields
setVideoSource(initialData.videoSource || 'vimeo');
setVideoUrl(initialData.videoUrl || initialData.vimeoUrl || '');
// Parse duration
if (initialData.duration) {
const durationMatch = initialData.duration.match(/(\d+)\s*hr\s*(\d+)\s*min\s*(\d+)\s*sec/);
if (durationMatch) {
setDurationHr(durationMatch[1] || '0');
setDurationMin(durationMatch[2] || '0');
setDurationSec(durationMatch[3] || '0');
}
}
// Reading fields
setArticleBody(initialData.articleBody || '');
// Exercise files
setExistingExerciseFiles(initialData.exerciseFiles || []);
} else {
resetForm();
}
setError('');
}
}, [show, initialData]);
const resetForm = () => {
setLessonType('video');
setTitle('');
setSummary('');
setIsPreview(false);
setVideoSource('vimeo');
setVideoUrl('');
setVideoFile(null);
setDurationHr('0');
setDurationMin('0');
setDurationSec('0');
setArticleBody('');
setExerciseFiles([]);
setExistingExerciseFiles([]);
setFilesToDelete([]);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const handleFileSelect = (e) => {
const files = Array.from(e.target.files);
setExerciseFiles(prev => [...prev, ...files]);
};
const removeNewFile = (index) => {
setExerciseFiles(prev => prev.filter((_, i) => i !== index));
};
const removeExistingFile = async (filePath) => {
if (!window.confirm('Are you sure you want to remove this file?')) return;
try {
const token = localStorage.getItem('lmsToken');
const response = await fetch(
`/api/courses/${courseId}/episodes/${episodeId}/lessons/${initialData._id}/files`,
{
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'x-auth-token': token,
},
body: JSON.stringify({ filePath }),
}
);
const result = await response.json();
if (result.success) {
setExistingExerciseFiles(prev => prev.filter(file => file.path !== filePath));
setFilesToDelete(prev => [...prev, filePath]);
} else {
alert(`Error: ${result.message}`);
}
} catch (error) {
alert('An error occurred while removing the file.');
}
};
const validateForm = () => {
if (!title.trim()) {
return 'Please enter a lesson title.';
}
if (lessonType === 'video') {
if (videoSource === 'local' && !videoFile && !initialData?.videoPath) {
return 'Please upload a video file or select an existing video.';
}
if ((videoSource === 'vimeo' || videoSource === 'youtube') && !videoUrl.trim()) {
return 'Please enter a video URL.';
}
} else if (lessonType === 'reading') {
if (!articleBody.trim()) {
return 'Please enter lesson content.';
}
}
return null;
};
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
setError('');
const validationError = validateForm();
if (validationError) {
setError(validationError);
setLoading(false);
return;
}
try {
const token = localStorage.getItem('lmsToken');
const isEditing = !!initialData;
const url = isEditing
? `/api/courses/${courseId}/episodes/${episodeId}/lessons/${initialData._id}`
: `/api/courses/${courseId}/episodes/${episodeId}/lessons`;
const formData = new FormData();
formData.append('lessonType', lessonType);
formData.append('title', title);
formData.append('summary', summary);
formData.append('isPreview', isPreview);
if (lessonType === 'video') {
formData.append('videoSource', videoSource);
if (videoSource === 'local' && videoFile) {
formData.append('videoFile', videoFile);
} else {
formData.append('videoUrl', videoUrl);
}
const duration = `${durationHr} hr ${durationMin} min ${durationSec} sec`;
formData.append('duration', duration);
} else {
formData.append('articleBody', articleBody);
formData.append('duration', '5 min'); // Default for reading
}
// Add exercise files
exerciseFiles.forEach(file => {
formData.append('exerciseFiles', file);
});
// Mark files to delete
if (filesToDelete.length > 0) {
formData.append('filesToDelete', JSON.stringify(filesToDelete));
}
const response = await fetch(url, {
method: isEditing ? 'PUT' : 'POST',
headers: { 'x-auth-token': token },
body: formData,
});
const result = await response.json();
if (result.success) {
onSave(); // Refresh curriculum
onClose(); // Close modal
resetForm();
} else {
setError(result.message || `Failed to ${isEditing ? 'update' : 'add'} lesson.`);
}
} catch (err) {
setError('An error occurred. Please try again.');
} finally {
setLoading(false);
}
};
if (!show) return null;
return ReactDOM.createPortal(
// Modal JSX (truncated for documentation)
<div className="tru-default-modal modal fade show d-block">
{/* Modal content matching your implementation */}
</div>,
document.body
);
};
export default LessonModal;
| Field | Type | Component | Validation |
|---|---|---|---|
| Lesson Type | Select | react-select |
Required |
| Lesson Title | Text Input | <input type="text"> |
Required, max 100 chars |
| Video Source | Select | react-select |
Required for video lessons |
| Video URL | Text Input | <input type="text"> |
Required for Vimeo/YouTube |
| Video File | File Input | <input type="file" accept="video/*"> |
Required for local uploads |
| Duration | Number Inputs (3) | <input type="number"> (hr/min/sec) |
Required for video lessons |
| Lesson Content | Rich Text | @tinymce/tinymce-react |
Required for reading lessons |
| Lesson Summary | Textarea | <textarea> |
Optional, max 500 chars |
| Exercise Files | Multi-file Upload | <input type="file" multiple> |
Optional, max 10 files |
| Preview Toggle | Switch | <input type="checkbox" role="switch"> |
Optional (currently hidden) |
multiple attributeexerciseFiles - New files to uploadexistingExerciseFiles - Already uploaded filesfilesToDelete - Files marked for deletion
// Headers
{
"x-auth-token": "Bearer <token>",
"Content-Type": "multipart/form-data"
}
// FormData fields
lessonType: "video"
title: "Introduction to Components"
summary: "Learn about React components"
isPreview: false
videoSource: "vimeo"
videoUrl: "https://vimeo.com/123456789"
duration: "1 hr 15 min 30 sec"
exerciseFiles: [File, File] // Multiple files
// Same fields as POST plus:
filesToDelete: ["uploads/pdfs/file1.pdf", "uploads/pdfs/file2.pdf"] // JSON stringified array
// Request Body
{
"filePath": "uploads/pdfs/1704038400000-assignment.pdf"
}
// Inside episodes array
lessons: [{
_id: ObjectId,
title: String,
summary: String,
lessonType: { type: String, enum: ['video', 'reading'] },
// Video-specific fields
videoSource: { type: String, enum: ['vimeo', 'youtube', 'local'] },
videoUrl: String, // For Vimeo/YouTube
videoPath: String, // For local uploads
duration: String, // Format: "1 hr 15 min 30 sec"
// Reading-specific fields
articleBody: String, // HTML content
// Common fields
isPreview: Boolean,
exerciseFiles: [{
filename: String,
path: String,
size: Number,
uploadedAt: Date
}],
createdAt: Date
}]
Solution: Ensure classNamePrefix matches your CSS:
<Select
classNamePrefix="tru-select" // Matches your CSS classes
className="react-select-container w-100"
/>
Error: "This domain is not registered with Tiny Cloud"
Solution: The API key in your component (0f3rfxypgq3slagbe1wg2dkk6vzc4c23f6gg268x74tht4u3) is valid. If issues occur:
| Issue | Solution |
|---|---|
| File size > 50MB | Compress or split into multiple files |
| Multiple files selected | Component handles arrays with exerciseFiles.forEach() |
| File deletion not working | Check filesToDelete is stringified: JSON.stringify(filesToDelete) |
ReactDOM.createPortal() for modal overlay@tinymce/tinymce-reactAdvanced assessment system with multi-step wizard interface, multiple question types, flexible settings, and automatic grading. Implemented in QuizModal.jsx with React hooks and react-select dropdowns.
Title and summary
33% progressAdd, edit, delete questions
66% progressConfigure quiz behavior
100% progressOne correct answer (Radio buttons)
Auto-gradedMultiple correct answers (Checkboxes)
Auto-gradedText answer, manual grading
Manual Grade
import React, { useState, useEffect, useCallback, useRef } from 'react';
import ReactDOM from 'react-dom';
import Select from 'react-select';
const QuizModal = ({ show, onClose, onSave, courseId, episodeId, initialData = null }) => {
// Wizard steps
const steps = {
INFO: 1,
QUESTIONS_LIST: 2,
ADD_EDIT_QUESTION_FORM: 3,
SETTINGS: 4
};
const [currentStep, setCurrentStep] = useState(steps.INFO);
const [quizId, setQuizId] = useState(null);
// Quiz info
const [title, setTitle] = useState('');
const [summary, setSummary] = useState('');
// Questions
const [questions, setQuestions] = useState([]);
const [currentEditingQuestionId, setCurrentEditingQuestionId] = useState(null);
const [questionText, setQuestionText] = useState('');
const [questionType, setQuestionType] = useState('single-choice');
const [questionPoints, setQuestionPoints] = useState(10);
const [answerOptions, setAnswerOptions] = useState([{ text: '', isCorrect: false }]);
// Settings
const [timeLimitValue, setTimeLimitValue] = useState(0);
const [timeLimitUnit, setTimeLimitUnit] = useState({ value: 'Hour', label: 'Hour' });
const [hideTimeLimit, setHideTimeLimit] = useState(false);
const [feedbackMode, setFeedbackMode] = useState('default');
const [passingGrade, setPassingGrade] = useState(50);
const [maxQuestionsAllowed, setMaxQuestionsAllowed] = useState(10);
const [autoStart, setAutoStart] = useState(false);
const [questionLayout, setQuestionLayout] = useState({ value: 'single_question', label: 'Single Question' });
const [questionOrder, setQuestionOrder] = useState({ value: 'Random', label: 'Random' });
const [hideQuestionNumber, setHideQuestionNumber] = useState(false);
const [shortAnswerCharLimit, setShortAnswerCharLimit] = useState(200);
const [essayCharLimit, setEssayCharLimit] = useState(500);
const [advanceSettingsOpen, setAdvancedSettingsOpen] = useState(false);
// State
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [questionError, setQuestionError] = useState('');
// Options for react-select
const timeUnitOptions = [
{ value: 'Weeks', label: 'Weeks' },
{ value: 'Day', label: 'Day' },
{ value: 'Hour', label: 'Hour' }
];
const questionTypeOptions = [
{ value: 'single-choice', label: 'Single Choice' },
{ value: 'multiple-choice', label: 'Multiple Choice' },
{ value: 'open-ended', label: 'Open Ended (Text Answer)' }
];
const questionOrderOptions = [
{ value: 'Random', label: 'Random' },
{ value: 'sorting', label: 'Sorting' },
{ value: 'asc', label: 'Ascending' },
{ value: 'desc', label: 'Descending' }
];
const questionLayoutOptions = [
{ value: 'single_question', label: 'Single Question' },
{ value: 'question_pagination', label: 'Question Pagination' },
{ value: 'question_below_each_other', label: 'Question below each other' }
];
const feedbackModeOptions = [
{ value: 'default', label: 'Default', description: 'Answers shown after quiz is finished' },
{ value: 'reveal', label: 'Reveal Mode', description: 'Show result after the attempt.' },
{ value: 'retry', label: 'Retry Mode', description: 'Reattempt quiz any number of times.' }
];
// Progress calculation
const calculateProgress = () => {
if (currentStep === steps.INFO) return 33.33;
if (currentStep === steps.QUESTIONS_LIST || currentStep === steps.ADD_EDIT_QUESTION_FORM) return 66.66;
if (currentStep === steps.SETTINGS) return 100;
return 0;
};
// Save quiz info (Step 1)
const saveQuizInfo = async () => {
if (!title.trim()) {
setError('Please enter a quiz title.');
return;
}
setLoading(true);
try {
const token = localStorage.getItem('lmsToken');
const isEditing = !!quizId;
const url = isEditing
? `/api/courses/${courseId}/episodes/${episodeId}/quizzes/${quizId}`
: `/api/courses/${courseId}/episodes/${episodeId}/quizzes`;
const response = await fetch(url, {
method: isEditing ? 'PUT' : 'POST',
headers: {
'Content-Type': 'application/json',
'x-auth-token': token,
},
body: JSON.stringify({ title, summary }),
});
const result = await response.json();
if (result.success) {
if (!isEditing) setQuizId(result.quiz._id);
setCurrentStep(steps.QUESTIONS_LIST);
setError('');
} else {
setError(result.message || 'Failed to save quiz info.');
}
} catch (err) {
setError('An error occurred. Please try again.');
} finally {
setLoading(false);
}
};
// Save complete quiz (Step 3)
const saveQuiz = async () => {
setLoading(true);
try {
const token = localStorage.getItem('lmsToken');
// Save questions first
for (const question of questions) {
const isTempId = question._id.startsWith('temp-');
const method = isTempId ? 'POST' : 'PUT';
const url = isTempId
? `/api/courses/${courseId}/episodes/${episodeId}/quizzes/${quizId}/questions`
: `/api/courses/${courseId}/episodes/${episodeId}/quizzes/${quizId}/questions/${question._id}`;
await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
'x-auth-token': token,
},
body: JSON.stringify({
questionText: question.questionText,
questionType: question.questionType,
points: question.points,
options: question.options || []
}),
});
}
// Save settings
const settingsData = {
title,
summary,
settings: {
timeLimit: {
value: timeLimitValue,
unit: timeLimitUnit.value === 'Hour' ? 'Hours' : timeLimitUnit.value
},
hideTimeLimit,
feedbackMode,
passingGrade,
maxQuestionsAllowed,
autoStart,
questionLayout: questionLayout.value,
questionOrder: questionOrder.value,
hideQuestionNumber,
shortAnswerCharLimit,
essayCharLimit
}
};
const response = await fetch(`/api/courses/${courseId}/episodes/${episodeId}/quizzes/${quizId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'x-auth-token': token,
},
body: JSON.stringify(settingsData),
});
const result = await response.json();
if (result.success) {
onSave();
onClose();
} else {
setError(result.message || 'Failed to save quiz.');
}
} catch (err) {
setError('An error occurred. Please try again.');
} finally {
setLoading(false);
}
};
return ReactDOM.createPortal(
<div className="tru-quiz-modal-container show">
{/* Modal JSX structure */}
</div>,
document.body
);
};
export default QuizModal;
<div className="course-field quiz-modal mb-4">
<div className="d-flex justify-content-between mb-2">
<span>Quiz info</span>
<span>Question</span>
<span>Settings</span>
</div>
<div className="position-relative m-4">
<div className="progress" role="progressbar">
<div
className="progress-bar tru-progress-bar"
data-progress={progress}
></div>
</div>
<button className="position-absolute top-0 start-0 translate-middle btn quiz-modal-btn">
<i className="feather-check"></i>
</button>
<button className="position-absolute top-0 start-50 translate-middle btn quiz-modal-btn">
2
</button>
<button className="position-absolute top-0 start-100 translate-middle btn quiz-modal-btn">
3
</button>
</div>
</div>
| Setting | Component | Default | Description |
|---|---|---|---|
| Time Limit | Number input + react-select | 0 (unlimited) | Countdown timer with Weeks/Day/Hour units |
| Hide Time Display | Switch toggle | false | Hide timer from students |
| Feedback Mode | Radio buttons with descriptions | default | Default, Reveal, or Retry mode |
| Passing Grade | Number input | 50% | Minimum percentage to pass |
| Max Questions | Number input | 10 | Randomly selected questions to show |
| Auto Start | Switch toggle | false | Start timer immediately on page load |
| Question Layout | react-select | Single Question | Display mode for questions |
| Question Order | react-select | Random | Order of questions presented |
| Hide Question Number | Switch toggle | false | Hide question numbering from students |
| Short Answer Limit | Number input | 200 chars | Character limit for short answers |
| Essay Answer Limit | Number input | 500 chars | Character limit for essay answers |
Displays all questions with type badges and edit/delete actions
{questions.map((q, index) => {
const badgeColor = q.questionType === 'single-choice' ? 'primary'
: q.questionType === 'multiple-choice' ? 'success'
: 'warning';
return (
<div key={q._id} className="d-flex justify-content-between align-items-center tru-course-wrapper">
<div className="inner d-flex align-items-center gap-3">
<h6 className="tru-title mb-0">
<strong>{index + 1}.</strong> {q.questionText}
</h6>
<span className={`badge bg-${badgeColor}`}>{q.questionType}</span>
</div>
<div className="inner">
<div className="tru-course-list d-flex gap-3">
<button onClick={() => startEditQuestion(q)}>
<i className="feather-edit tru-action-icon"></i>
</button>
<button onClick={() => deleteQuestion(q._id)}>
<i className="feather-trash tru-action-icon"></i>
</button>
</div>
</div>
</div>
);
})}
Dynamic form with conditional rendering based on question type
<div className="row">
<div className="col-lg-6">
<div className="course-field mb-3 d-flex align-items-center">
<label className="tru-form-label mb-0 me-3 text-nowrap">
Question Type
</label>
<div className="w-100">
<Select
options={questionTypeOptions}
value={questionTypeOptions.find(opt => opt.value === questionType)}
onChange={handleQuestionTypeChange}
classNamePrefix="tru-select"
/>
</div>
</div>
</div>
<div className="col-lg-6">
<div className="course-field mb-3 d-flex align-items-center">
<label className="tru-form-label mb-0 me-3">
Points
</label>
<input
type="number"
className="form-control tru-input-aligned"
value={questionPoints}
onChange={(e) => setQuestionPoints(parseInt(e.target.value) || 10)}
/>
</div>
</div>
</div>
{answerOptions.map((option, index) => (
<div key={index} className="d-flex align-items-center mb-2 quiz-option-row">
<div className="flex-grow-1 me-2">
<input
type="text"
className="form-control quiz-option-text"
placeholder="Answer option text"
value={option.text}
onChange={(e) => updateAnswerOption(index, 'text', e.target.value)}
/>
</div>
<div className="form-check me-3">
{questionType === 'single-choice' ? (
<input
className="form-check-input quiz-option-iscorrect"
type="radio"
name={`correct-option-group`}
checked={option.isCorrect}
onChange={() => updateAnswerOption(index, 'isCorrect', true)}
/>
) : (
<input
className="form-check-input quiz-option-iscorrect"
type="checkbox"
checked={option.isCorrect}
onChange={(e) => updateAnswerOption(index, 'isCorrect', e.target.checked)}
/>
)}
</div>
{answerOptions.length > 1 && (
<button onClick={() => removeAnswerOption(index)}>
<i className="feather-x"></i>
</button>
)}
</div>
))}
// Request
{
"title": "JavaScript Fundamentals Quiz",
"summary": "Test your basic JavaScript knowledge"
}
// Single Choice Question
{
"questionText": "What is the capital of France?",
"questionType": "single-choice",
"points": 10,
"options": [
{ "text": "London", "isCorrect": false },
{ "text": "Berlin", "isCorrect": false },
{ "text": "Paris", "isCorrect": true },
{ "text": "Madrid", "isCorrect": false }
]
}
// Multiple Choice Question
{
"questionText": "Which are JavaScript frameworks?",
"questionType": "multiple-choice",
"points": 15,
"options": [
{ "text": "React", "isCorrect": true },
{ "text": "Angular", "isCorrect": true },
{ "text": "Django", "isCorrect": false },
{ "text": "Vue.js", "isCorrect": true }
]
}
{
"title": "Updated Quiz Title",
"summary": "New description",
"settings": {
"timeLimit": { "value": 45, "unit": "Hours" },
"hideTimeLimit": true,
"feedbackMode": "retry",
"passingGrade": 80,
"maxQuestionsAllowed": 20,
"autoStart": true,
"questionLayout": "single_question",
"questionOrder": "asc",
"hideQuestionNumber": false,
"shortAnswerCharLimit": 250,
"essayCharLimit": 1000
}
}
// Inside episodes array
quizzes: [{
_id: ObjectId,
title: String,
summary: String,
// Settings
timeLimit: {
value: Number,
unit: String // 'Weeks', 'Day', 'Hours'
},
hideTimeLimit: Boolean,
feedbackMode: { type: String, enum: ['default', 'reveal', 'retry'] },
passingGrade: Number,
maxQuestionsAllowed: Number,
autoStart: Boolean,
questionLayout: {
type: String,
enum: ['single_question', 'question_pagination', 'question_below_each_other']
},
questionOrder: { type: String, enum: ['Random', 'sorting', 'asc', 'desc'] },
hideQuestionNumber: Boolean,
shortAnswerCharLimit: Number,
essayCharLimit: Number,
// Questions
questions: [{
_id: ObjectId,
questionText: String,
questionType: {
type: String,
enum: ['single-choice', 'multiple-choice', 'open-ended']
},
points: Number,
options: [{
_id: ObjectId,
text: String,
isCorrect: Boolean
}]
}],
createdAt: { type: Date, default: Date.now }
}]
Debug steps:
// 1. Check if quiz ID exists (Step 1 must be completed first)
console.log('Quiz ID:', quizId);
// 2. Verify temporary IDs are being handled correctly
questions.forEach(q => {
const isTemp = q._id.startsWith('temp-');
console.log(`Question ${q._id}: ${isTemp ? 'Will use POST' : 'Will use PUT'}`);
});
// 3. Check network request for each question
fetch(`/api/courses/${courseId}/episodes/${episodeId}/quizzes/${quizId}/questions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-auth-token': token
},
body: JSON.stringify(questionData)
})
.then(response => {
console.log('Response status:', response.status);
return response.json();
})
.then(data => {
console.log('Server response:', data);
})
.catch(error => {
console.error('Fetch error:', error);
});
Common fixes:
quizId is set before calling saveQuiz()Common causes:
| Issue | Solution |
|---|---|
| Option IDs mismatch in submission | Regenerate question to get new ObjectIds or ensure frontend sends correct IDs |
| Question type mislabeled | Verify questionType in database matches frontend enum |
| Multiple correct answers not selected | Check "isCorrect" flags on all options - multiple-choice allows multiple true values |
| Open-ended marked as auto-graded | Set questionType: 'open-ended' for manual grading |
| Radio button name grouping broken | Ensure single-choice questions have same name attribute for all options |
Grading logic reference:
// From server - POST /api/courses/:courseId/quizzes/:quizId/submit
if (question.questionType === 'single-choice') {
// Exactly one option must match
const submittedId = Array.isArray(submitted) ? submitted[0] : submitted;
isCorrect = correctOptionIds.includes(submittedId);
}
else if (question.questionType === 'multiple-choice') {
// All correct options must be selected, no incorrect ones
const submittedIds = submitted || [];
isCorrect = submittedIds.length === correctOptionIds.length &&
submittedIds.every(id => correctOptionIds.includes(id));
}
Check portal rendering:
// Ensure modal is using ReactDOM.createPortal
return ReactDOM.createPortal(
<div className={`tru-quiz-modal-container ${show ? 'show' : ''}`}>
{/* modal content */}
</div>,
document.body
);
// Backdrop click handler should check target
const handleBackdropClick = useCallback((e) => {
if (modalContentRef.current &&
!modalContentRef.current.contains(e.target) &&
modalRef.current &&
modalRef.current.contains(e.target)) {
handleClose();
}
}, [handleClose]);
// Escape key handler
useEffect(() => {
if (show) {
document.addEventListener('keydown', handleEscape);
document.body.classList.add('tru-modal-open');
return () => {
document.removeEventListener('keydown', handleEscape);
document.body.classList.remove('tru-modal-open');
};
}
}, [show, handleEscape]);
Solution: Ensure classNamePrefix matches your CSS:
<Select
classNamePrefix="tru-select" // Must match CSS classes
className="react-select-container"
menuPlacement="auto"
menuPosition="fixed" // Prevents overflow issues
/>
For dropdowns inside modals with overflow:
// Add these props to prevent clipping
<Select
menuPortalTarget={document.body} // Render dropdown in portal
styles={{
menuPortal: (base) => ({ ...base, zIndex: 9999 })
}}
/>
Check calculateProgress function:
const calculateProgress = () => {
if (currentStep === steps.INFO) return 33.33;
if (currentStep === steps.QUESTIONS_LIST ||
currentStep === steps.ADD_EDIT_QUESTION_FORM) return 66.66;
if (currentStep === steps.SETTINGS) return 100;
return 0;
};
// In JSX - using data attribute instead of inline style
<div
className="progress-bar tru-progress-bar"
data-progress={progress}
></div>
// CSS should handle the width
.tru-progress-bar {
width: attr(data-progress %);
}
temp-${Date.now()}) for new questionsAutomated PDF certificate generation for students who complete courses. Certificates are dynamically generated with student details, course information, and school branding.
Student progress is tracked automatically when they:
Certificate becomes available when:
"completed"courseLogo set by instructorincludesCertificate: true in course settingsWhen student clicks "Download Certificate":
{
"format": "PNG or JPG",
"dimensions": "Square (1:1 ratio)",
"size": "500x500 pixels (recommended)",
"maxSize": "5MB",
"backgroundColor": "Transparent or white",
"storage": "uploads/logos/ directory"
}
Logo appears in certificate at 172x172 pixels.
// Headers: Requires authentication
{
"x-auth-token": "Bearer <student_token>"
}
// Response: PDF file with headers:
Content-Type: application/pdf
Content-Disposition: attachment; filename="certificate-{course-slug}.pdf"
// Flow:
1. Checks if student has completed the course
2. Validates course has logo and certificate enabled
3. Loads template from: backend/templates/certificates/template-blank.pdf
4. Adds dynamic content
5. Returns PDF file for download
// Frontend Usage (React Component)
import React, { useState } from 'react';
const CertificateDownload = ({ courseId, courseSlug, isCompleted }) => {
const [downloading, setDownloading] = useState(false);
const downloadCertificate = async () => {
if (!isCompleted) return;
setDownloading(true);
try {
const token = localStorage.getItem('lmsToken');
const response = await fetch(`/api/certificate/${courseId}/download`, {
headers: { 'x-auth-token': token }
});
if (response.ok) {
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `certificate-${courseSlug || courseId}.pdf`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
}
} catch (err) {
console.error('Download failed:', err);
} finally {
setDownloading(false);
}
};
if (!isCompleted) return null;
return (
<button
onClick={downloadCertificate}
disabled={downloading}
className="tru-btn btn-gradient"
>
{downloading ? 'Generating...' : 'Download Certificate'}
</button>
);
};
export default CertificateDownload;
// In server.js - Certificate Download Route
app.get('/api/certificate/:courseId/download', auth, async (req, res) => {
try {
const { courseId } = req.params;
const userId = req.user.id;
// 1. Check if student has completed the course
const user = await User.findById(userId);
const enrollment = user.enrolledCourses.find(e => e.course.equals(courseId));
if (!enrollment || enrollment.status !== 'completed') {
return res.status(403).send('Access Denied: You have not completed this course.');
}
// 2. Check if course has certificate enabled
const course = await Course.findById(courseId);
if (!course || !course.courseLogo) {
return res.status(404).send('Certificate cannot be generated because a course logo has not been set.');
}
// 3. Prepare dynamic content
const studentName = `${user.firstName} ${user.lastName}`;
const courseName = course.title;
const schoolName = course.school;
const completionYear = new Date().getFullYear().toString();
// 4. Load required files
const templatePath = path.join(__dirname, 'templates', 'certificates', 'template-blank.pdf');
const logoPath = path.join(__dirname, '..', 'frontend', 'static', course.courseLogo);
const fontPath = path.join(__dirname, 'fonts', 'CormorantUnicase-Light.ttf');
const pdfTemplateBytes = await fs.promises.readFile(templatePath);
const logoImageBytes = await fs.promises.readFile(logoPath);
const fontBytes = await fs.promises.readFile(fontPath);
// 5. Create PDF document
const pdfDoc = await PDFDocument.load(pdfTemplateBytes);
// Register custom font
pdfDoc.registerFontkit(fontkit);
const schoolFont = await pdfDoc.embedFont(fontBytes);
const otherFont = await pdfDoc.embedFont(StandardFonts.Helvetica);
const otherBoldFont = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
const firstPage = pdfDoc.getPages()[0];
const { width, height } = firstPage.getSize();
const logoImage = await pdfDoc.embedPng(logoImageBytes);
// 6. Draw content onto PDF
// Logo
firstPage.drawImage(logoImage, {
x: 309.87,
y: 406.85,
width: 172,
height: 172
});
// School Name (custom font)
firstPage.drawText(schoolName || '', {
x: 210.87,
y: 380.42,
font: schoolFont,
size: 25,
color: rgb(1, 1, 1), // White
});
// Student Name (centered)
const studentNameWidth = otherBoldFont.widthOfTextAtSize(studentName, 15);
firstPage.drawText(studentName, {
x: (width - studentNameWidth) / 2,
y: 285.08,
font: otherBoldFont,
size: 15,
color: rgb(0.30, 0.30, 0.30), // Dark gray
});
// Course Name (centered)
const courseNameWidth = otherBoldFont.widthOfTextAtSize(courseName, 15);
firstPage.drawText(courseName, {
x: (width - courseNameWidth) / 2,
y: 235.92,
font: otherBoldFont,
size: 15,
color: rgb(0.30, 0.30, 0.30),
});
// Completion Year
firstPage.drawText(completionYear, {
x: 677.56,
y: 58.42,
font: otherFont,
size: 12,
color: rgb(1, 1, 1),
});
// 7. Send PDF
const pdfBytes = await pdfDoc.save();
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename="certificate-${course.slug}.pdf"`);
res.send(Buffer.from(pdfBytes));
} catch (error) {
console.error('Error generating certificate:', error);
res.status(500).send('Could not generate certificate.');
}
});
// In models/Course.js
const CourseSchema = new mongoose.Schema({
// ... other fields ...
// Required for certificates
courseLogo: {
type: String,
required: false // But required for certificate generation
},
school: {
type: String,
default: ''
},
includesCertificate: {
type: Boolean,
default: false
},
// Optional: For future template system
certificateTemplate: {
type: String,
default: 'template-blank'
},
certificateOrientation: {
type: String,
enum: ['landscape', 'portrait'],
default: 'landscape'
}
});
backend/
├── templates/
│ └── certificates/
│ └── template-blank.pdf # Base certificate template
├── fonts/
│ └── CormorantUnicase-Light.ttf # Custom font for school name
├── server.js # Certificate generation route
└── models/
└── Course.js # Course schema with certificate fields
client/
├── public/
│ └── uploads/
│ ├── avatars/ # User profile pictures
│ ├── covers/ # Course cover images
│ ├── logos/ # Course logos for certificates
│ ├── pdfs/ # Exercise files
│ ├── resources/ # Additional course resources
│ ├── thumbnails/ # Course thumbnails
│ └── videos/ # Local video uploads
│
└── src/
└── pages/
└── student/
└── StudentEnrolledCourses.jsx # Contains certificate download logic
certificate-{course-slug}.pdf
// In server.js - When lesson is completed
app.post('/api/courses/:courseId/lessons/:lessonId/complete', auth, async (req, res) => {
try {
const { courseId, lessonId } = req.params;
const userId = req.user.id;
const course = await Course.findById(courseId).populate({
path: 'episodes',
populate: { path: 'lessons quizzes' }
});
const user = await User.findById(userId);
const enrollment = user.enrolledCourses.find(e => e.course.equals(courseId));
if (!enrollment.completedLessons.includes(lessonId)) {
enrollment.completedLessons.push(lessonId);
}
// Calculate total items in course
const totalLessons = course.episodes.reduce((sum, episode) => sum + episode.lessons.length, 0);
const totalQuizzes = course.episodes.reduce((sum, episode) => sum + episode.quizzes.length, 0);
const totalItems = totalLessons + totalQuizzes;
// Calculate completed items
const completedItems = enrollment.completedLessons.length + enrollment.completedQuizzes.length;
const progress = totalItems > 0 ? Math.round((completedItems / totalItems) * 100) : 0;
enrollment.progress = progress;
// Mark as completed at 100%
if (progress === 100) {
enrollment.status = 'completed';
}
await user.save();
res.json({
success: true,
progress: enrollment.progress,
status: enrollment.status
});
} catch (error) {
console.error('Error marking lesson complete:', error);
res.status(500).json({ success: false, message: 'Server error' });
}
});
Common issues and solutions:
| Error Message | Cause | Solution |
|---|---|---|
"Course logo has not been set" |
Instructor didn't upload course logo | Edit course → Upload square logo |
"You have not completed this course" |
Progress < 100% | Complete all lessons and quizzes |
"Could not generate certificate" |
Missing template or font files | Verify files exist in backend/templates/ and backend/fonts/ |
| PDF downloads but is blank | Template PDF corruption | Replace template-blank.pdf with original |
Debug steps:
// Check user's enrollment status
const user = await User.findById(userId);
const enrollment = user.enrolledCourses.find(e => e.course.equals(courseId));
console.log('Enrollment status:', {
progress: enrollment.progress,
status: enrollment.status,
completedLessons: enrollment.completedLessons.length,
completedQuizzes: enrollment.completedQuizzes.length
});
// Check course structure
const course = await Course.findById(courseId);
console.log('Course structure:', {
totalLessons: course.episodes.reduce((sum, ep) => sum + ep.lessons.length, 0),
totalQuizzes: course.episodes.reduce((sum, ep) => sum + ep.quizzes.length, 0),
episodes: course.episodes.length
});
// Manual fix if needed (admin only)
enrollment.progress = 100;
enrollment.status = 'completed';
await user.save();
Check conditional rendering in StudentEnrolledCourses.jsx:
// Ensure the button only renders when course is completed
{course.status === 'completed' && (
<button
onClick={() => handleDownloadCertificate(course._id, course.slug)}
className="tru-btn btn-gradient"
>
Download Certificate
</button>
)}
// Debug by logging course status
console.log('Course:', course.title, 'Status:', course.status);
// Check if API returns correct status
const fetchEnrolledCourses = async () => {
const response = await fetch('/api/student/enrolled-courses', {
headers: { 'x-auth-token': token }
});
const data = await response.json();
console.log('Enrolled courses:', data.courses);
};
Common issues:
course.status is not exactly "completed" (check for uppercase/lowercase)template-blank.pdf with custom designCormorantUnicase-Light.ttf to any TrueType fontfirstPage.drawText() calls
// To add more fields, modify the PDF drawing section:
// Example: Add completion date
const completionDate = new Date().toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
firstPage.drawText(`Completed on: ${completionDate}`, {
x: 300,
y: 200,
font: otherFont,
size: 10,
color: rgb(0.5, 0.5, 0.5)
});
// Example: Add instructor name
if (course.instructor) {
firstPage.drawText(`Instructor: ${course.instructor.name}`, {
x: 300,
y: 180,
font: otherFont,
size: 10,
color: rgb(0.5, 0.5, 0.5)
});
}
// Example: Change logo position/size
firstPage.drawImage(logoImage, {
x: 400, // Horizontal position
y: 500, // Vertical position
width: 100, // Smaller logo
height: 100
});
Complete REST API documentation for the Course Builder system. All endpoints require JWT authentication with appropriate user roles.
x-auth-token header with JWT token. Instructor endpoints require
role: 'instructor'.
| Method | Endpoint | Description | Role | Request Body |
|---|---|---|---|---|
| POST | /api/instructor/courses |
Create new course (Step 1) | Instructor | multipart/form-data |
| GET | /api/courses/edit/:courseId |
Get course for editing | Instructor | - |
| PUT | /api/courses/:courseId |
Update course details | Instructor | multipart/form-data |
| GET | /api/courses/preview/:courseId |
Preview course (instructor view) | Instructor | - |
| Method | Endpoint | Description | Request Body | Response |
|---|---|---|---|---|
| POST | /api/courses/:courseId/episodes |
Add new topic | {title, summary} |
Updated course with new episode |
| PUT | /api/courses/:courseId/episodes/:episodeId |
Update topic | {title, summary} |
Updated course |
| DELETE | /api/courses/:courseId/episodes/:episodeId |
Delete topic | - | Updated course without episode |
| Method | Endpoint | Description | Content-Type | File Uploads |
|---|---|---|---|---|
| POST | /api/courses/:courseId/episodes/:episodeId/lessons |
Add new lesson | multipart/form-data | Video file, Exercise files |
| PUT | /api/courses/:courseId/episodes/:episodeId/lessons/:lessonId |
Update lesson | multipart/form-data | Video file, Exercise files |
| DELETE | /api/courses/:courseId/episodes/:episodeId/lessons/:lessonId |
Delete lesson | - | - |
| DELETE | /api/courses/:courseId/episodes/:episodeId/lessons/:lessonId/files |
Remove exercise file | application/json | - |
| POST | /api/courses/:courseId/lessons/:lessonId/complete |
Mark lesson as complete (student) | - | - |
| Method | Endpoint | Description | Request Body |
|---|---|---|---|
| POST | /api/courses/:courseId/episodes/:episodeId/quizzes |
Create new quiz | {title, summary, settings} |
| PUT | /api/courses/:courseId/episodes/:episodeId/quizzes/:quizId |
Update quiz settings | {title, summary, settings} |
| DELETE | /api/courses/:courseId/episodes/:episodeId/quizzes/:quizId |
Delete quiz | - |
| POST | /api/courses/:courseId/episodes/:episodeId/quizzes/:quizId/questions |
Add question to quiz | Question object |
| PUT |
/api/courses/:courseId/episodes/:episodeId/quizzes/:quizId/questions/:questionId
|
Update question | Updated question object |
| DELETE |
/api/courses/:courseId/episodes/:episodeId/quizzes/:quizId/questions/:questionId
|
Delete question | - |
| POST | /api/courses/:courseId/quizzes/:quizId/submit |
Submit quiz (student) | {answers: {}} |
| Method | Endpoint | Description | Role | Response |
|---|---|---|---|---|
| GET | /api/certificate/:courseId/download |
Download certificate PDF | Student | PDF file |
| GET | /api/student/my-courses |
Get enrolled courses with progress | Student | Courses with progress data |
| GET | /api/student/my-quiz-attempts |
Get quiz attempt history | Student | Quiz results array |
| GET | /api/quiz-results/:resultId |
Get detailed quiz result | Student/Instructor | Quiz result with answers |
// JavaScript
const formData = new FormData();
formData.append('title', 'Web Development Bootcamp');
formData.append('slug', 'web-dev-bootcamp');
formData.append('description', 'Learn full-stack web development...');
formData.append('thumbnail', thumbnailFile);
fetch('/api/instructor/courses', {
method: 'POST',
headers: { 'x-auth-token': token },
body: formData
});
// cURL
curl -X POST http://localhost:5000/api/instructor/courses \
-H "x-auth-token: YOUR_JWT_TOKEN" \
-F "title=Web Development Bootcamp" \
-F "slug=web-dev-bootcamp" \
-F "thumbnail=@thumbnail.jpg"
const formData = new FormData();
formData.append('title', 'Updated Course Title');
formData.append('price', '49.99');
formData.append('originalPrice', '99.99');
formData.append('status', 'Published');
formData.append('difficultyLevel', 'Intermediate');
formData.append('school', 'School of Code & Development');
formData.append('categories', JSON.stringify(['Web Development', 'JavaScript']));
formData.append('isMasterclass', 'true');
// Optional file updates
if (newThumbnail) formData.append('thumbnail', newThumbnail);
if (newLogo) formData.append('courseLogo', newLogo);
fetch(`/api/courses/${courseId}`, {
method: 'PUT',
headers: { 'x-auth-token': token },
body: formData
});
const formData = new FormData();
formData.append('lessonType', 'video');
formData.append('title', 'Introduction to React Components');
formData.append('summary', 'Learn the basics of React components');
formData.append('videoSource', 'vimeo');
formData.append('videoUrl', 'https://vimeo.com/123456789');
formData.append('duration', '1 hr 15 min 30 sec');
// Add exercise files
for (let file of exerciseFiles) {
formData.append('exerciseFiles', file);
}
fetch(`/api/courses/${courseId}/episodes/${episodeId}/lessons`, {
method: 'POST',
headers: { 'x-auth-token': token },
body: formData
});
| HTTP Code | Error Message | Cause | Solution |
|---|---|---|---|
| 401 | "No token, authorization denied" |
Missing or invalid JWT token | Login again to get new token |
| 403 | "Access denied" |
Wrong user role or not course owner | Check user role and course ownership |
| 404 | "Course not found" |
Invalid course ID | Verify course exists and ID is correct |
| 400 | "Course thumbnail is required" |
Missing required field | Provide all required form fields |
| 500 | "Server error" |
Backend error | Check server logs and database connection |
// Success Response
{
"success": true,
"message": "Operation completed successfully",
"data": { ... }, // Optional: Response data
"course": { ... } // For course operations: updated course
}
// Error Response
{
"success": false,
"message": "Error description",
"error": "Detailed error message" // Optional
}
// Paginated Response
{
"success": true,
"data": [...],
"pagination": {
"currentPage": 1,
"totalPages": 5,
"totalItems": 50,
"itemsPerPage": 10
}
}
// Get token after login
const response = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: 'email@example.com', password: 'password' })
});
const data = await response.json();
const token = data.token; // JWT token
localStorage.setItem('lmsToken', token);
// Use token in requests
fetch('/api/instructor/courses', {
headers: {
'x-auth-token': token,
'Content-Type': 'application/json'
}
});
// Token payload structure (decoded)
{
"user": {
"id": "user_id",
"role": "instructor", // or "student"
"name": "John Doe",
"avatar": "uploads/avatars/filename.jpg"
},
"iat": 1704038400,
"exp": 1704056400 // 5-hour expiration
}
multipart/form-data and have specific Multer configurations for different file types.
| Endpoint | Field Name | File Type | Max Size | Storage Location |
|---|---|---|---|---|
/api/instructor/courses |
thumbnail |
Image | 5MB | uploads/thumbnails/ |
/api/courses/:courseId |
courseLogo |
Image | 5MB | uploads/logos/ |
/api/courses/../lessons |
videoFile |
Video | 500MB | uploads/videos/ |
/api/courses/../lessons |
exerciseFiles |
PDF/DOC/ZIP | 50MB each | uploads/pdfs/ |
/api/user/avatar |
avatar |
Image | 2MB | uploads/avatars/ |
Learn how to customize and extend the Course Builder system to fit your specific needs. This guide covers SCSS theming, JavaScript modifications, and backend extensions.
src/assets/scss/ for easy customization. All styles are compiled through main.scss.
src/
└── assets/
├── scss/
│ ├── _variables.scss # Global variables (colors, fonts, spacing)
│ ├── main.scss # Main compilation file
│ └── components/ # Modular component files
│ ├── _course.scss # Course-related styles
│ ├── _course-builder.scss # Course builder specific
│ ├── _lesson.scss # Lesson player styles
│ ├── _quiz.scss # Quiz interface styles
│ ├── _certificate.scss # Certificate-related styles
│ ├── _buttons.scss # Button variations
│ ├── _forms.scss # Form styling
│ ├── _modals.scss # Modal windows
│ └── ... 70+ more files
└── css/
└── main.css # Compiled CSS (imported in App.jsx)
// Primary Colors $primary: #6C5CE7; // Main brand color $secondary: #A29BFE; // Secondary accent $success: #00B894; // Success states $warning: #FDCB6E; // Warnings $danger: #D63031; // Errors/danger $info: #0984E3; // Information // Course Builder Specific $course-builder-bg: #F8F9FA; $course-builder-border: #E9ECEF; $lesson-video-bg: #1A1A1A; $quiz-option-correct: #D4EDDA; $quiz-option-incorrect: #F8D7DA; // Gradient Definitions $gradient-primary: linear-gradient(135deg, $primary 0%, $secondary 100%); $gradient-success: linear-gradient(135deg, $success 0%, #00CEA9 100%); // To customize: // 1. Change these variables // 2. Run: npm run scss:compile // 3. Refresh browser
// Create: scss/components/_custom-lesson.scss
.lesson-player-custom {
// Override default styles
.video-wrapper {
border-radius: 20px;
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
overflow: hidden;
video {
width: 100%;
height: auto;
}
}
.lesson-controls {
background: rgba(0,0,0,0.8);
padding: 15px;
border-radius: 0 0 20px 20px;
.btn-control {
background: $primary;
color: white;
border: none;
padding: 10px 20px;
border-radius: 10px;
&:hover {
background: darken($primary, 10%);
transform: translateY(-2px);
}
}
}
.exercise-files {
background: white;
border-radius: 15px;
padding: 20px;
margin-top: 20px;
.file-item {
display: flex;
align-items: center;
padding: 10px;
border-bottom: 1px solid $course-builder-border;
&:last-child {
border-bottom: none;
}
.file-icon {
color: $primary;
margin-right: 10px;
}
.file-download {
margin-left: auto;
background: $gradient-primary;
color: white;
padding: 5px 15px;
border-radius: 5px;
}
}
}
}
// Then import in main.scss:
// @import 'components/custom-lesson';
// src/utils/courseValidation.js
export const validateCourse = (formData, thumbnail) => {
const errors = [];
if (!formData.title || formData.title.trim().length < 10) {
errors.push('Course title must be at least 10 characters');
}
if (formData.title && formData.title.length > 100) {
errors.push('Course title must be less than 100 characters');
}
const slugRegex = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
if (!slugRegex.test(formData.slug)) {
errors.push('Slug can only contain lowercase letters, numbers, and hyphens');
}
if (!thumbnail) {
errors.push('Course thumbnail is required');
}
return errors;
};
// Usage in CreateCourse.jsx
import { validateCourse } from '../../utils/courseValidation';
const handleSubmit = async (e) => {
e.preventDefault();
const validationErrors = validateCourse(formData, thumbnail);
if (validationErrors.length > 0) {
setError(validationErrors.join('\n'));
return;
}
// Proceed with submission
};
// src/hooks/useCourseProgress.js
import { useState, useEffect } from 'react';
import { useAuth } from '../context/AuthContext';
export const useCourseProgress = (courseId) => {
const { user } = useAuth();
const [progress, setProgress] = useState(0);
const [completedItems, setCompletedItems] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!user || !courseId) return;
const fetchProgress = async () => {
try {
const token = localStorage.getItem('lmsToken');
const response = await fetch(`/api/courses/${courseId}/progress`, {
headers: { 'x-auth-token': token }
});
const data = await response.json();
if (data.success) {
setProgress(data.progress);
setCompletedItems(data.completedItems);
}
} catch (error) {
console.error('Error fetching progress:', error);
} finally {
setLoading(false);
}
};
fetchProgress();
}, [courseId, user]);
const markLessonComplete = async (lessonId) => {
try {
const token = localStorage.getItem('lmsToken');
const response = await fetch(`/api/courses/${courseId}/lessons/${lessonId}/complete`, {
method: 'POST',
headers: { 'x-auth-token': token }
});
const data = await response.json();
if (data.success) {
setProgress(data.progress);
setCompletedItems(prev => [...prev, { type: 'lesson', id: lessonId }]);
}
} catch (error) {
console.error('Error marking lesson complete:', error);
}
};
return { progress, completedItems, loading, markLessonComplete };
};
// Usage in LessonPage.jsx
const LessonPage = () => {
const { courseId, lessonId } = useParams();
const { progress, markLessonComplete } = useCourseProgress(courseId);
const handleComplete = async () => {
await markLessonComplete(lessonId);
};
return (
<div>
<div className="progress-bar" style={{ width: `${progress}%` }} />
<button onClick={handleComplete}>Mark Complete</button>
</div>
);
};
// 1. Extend LessonModal.jsx to support new types
const lessonTypeOptions = [
{ value: 'video', label: 'Video Lesson' },
{ value: 'reading', label: 'Reading / Article Lesson' },
{ value: 'interactive', label: 'Interactive Exercise' }, // New type
{ value: 'audio', label: 'Audio Lesson' } // New type
];
// 2. Add conditional rendering for new types
{lessonType === 'interactive' && (
<div className="interactive-fields-wrapper">
<div className="course-field mb--20">
<label>Interactive Content URL</label>
<input
type="text"
value={interactiveUrl}
onChange={(e) => setInteractiveUrl(e.target.value)}
placeholder="https://codepen.io/embed/..."
/>
<small>Embed URL for CodePen, JSFiddle, etc.</small>
</div>
</div>
)}
{lessonType === 'audio' && (
<div className="audio-fields-wrapper">
<div className="course-field mb--20">
<label>Audio File</label>
<input
type="file"
accept="audio/*"
onChange={(e) => setAudioFile(e.target.files[0])}
/>
</div>
</div>
)}
// 3. Update form submission
if (lessonType === 'interactive') {
formData.append('interactiveUrl', interactiveUrl);
} else if (lessonType === 'audio') {
formData.append('audioFile', audioFile);
}
// src/context/GamificationContext.jsx
import React, { createContext, useContext, useState, useEffect } from 'react';
import { useAuth } from './AuthContext';
const GamificationContext = createContext();
export const useGamification = () => {
const context = useContext(GamificationContext);
if (!context) {
throw new Error('useGamification must be used within GamificationProvider');
}
return context;
};
export const GamificationProvider = ({ children }) => {
const { user } = useAuth();
const [points, setPoints] = useState(0);
const [badges, setBadges] = useState([]);
const [level, setLevel] = useState(1);
useEffect(() => {
if (user) {
fetchGamificationData();
}
}, [user]);
const fetchGamificationData = async () => {
try {
const token = localStorage.getItem('lmsToken');
const response = await fetch('/api/user/gamification', {
headers: { 'x-auth-token': token }
});
const data = await response.json();
if (data.success) {
setPoints(data.points);
setBadges(data.badges);
setLevel(data.level);
}
} catch (error) {
console.error('Error fetching gamification data:', error);
}
};
const awardPoints = async (action, value) => {
try {
const token = localStorage.getItem('lmsToken');
const response = await fetch('/api/gamification/award', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-auth-token': token
},
body: JSON.stringify({ action, value })
});
const data = await response.json();
if (data.success) {
setPoints(prev => prev + data.pointsAwarded);
if (data.newBadges) {
setBadges(prev => [...prev, ...data.newBadges]);
}
if (data.levelUp) {
setLevel(data.newLevel);
}
}
} catch (error) {
console.error('Error awarding points:', error);
}
};
return (
<GamificationContext.Provider value={{ points, badges, level, awardPoints }}>
{children}
</GamificationContext.Provider>
);
};
// Wrap in App.jsx
<GamificationProvider>
<ComponentInitializer>
{/* routes */}
</ComponentInitializer>
</GamificationProvider>
// Usage in any component
const LessonPage = () => {
const { awardPoints } = useGamification();
const handleComplete = async () => {
await markLessonComplete(lessonId);
awardPoints('lesson_complete', 10);
};
};
// In models/Course.js - Add custom fields
const CourseSchema = new mongoose.Schema({
// ... existing fields ...
// Custom metadata fields
customMetadata: {
seoTitle: String,
seoDescription: String,
metaKeywords: [String],
featuredUntil: Date,
priority: { type: Number, default: 0 },
// Social media
socialImage: String,
twitterCard: String,
openGraphTags: Map,
// Custom fields for your use case
learningOutcomes: [{
outcome: String,
measurable: Boolean,
assessmentMethod: String
}],
prerequisites: [{
type: { type: String, enum: ['course', 'skill', 'knowledge'] },
description: String,
required: Boolean
}],
// Certification details
certification: {
provider: String,
hours: Number,
accreditation: String,
renewalPeriod: Number // in months
},
// Custom scheduling
schedule: {
startDate: Date,
endDate: Date,
liveSessions: [{
title: String,
date: Date,
duration: Number, // minutes
instructor: String,
recordingUrl: String
}],
officeHours: [{
day: String,
time: String,
duration: Number
}]
}
},
// Analytics tracking
analytics: {
views: { type: Number, default: 0 },
enrollments: { type: Number, default: 0 },
completionRate: Number,
avgRating: Number,
revenue: Number,
lastUpdated: { type: Date, default: Date.now }
},
// Custom flags
flags: {
featured: { type: Boolean, default: false },
newRelease: { type: Boolean, default: true },
staffPick: { type: Boolean, default: false },
trending: { type: Boolean, default: false },
evergreen: { type: Boolean, default: false } // Always relevant
}
}, {
timestamps: true // Adds createdAt and updatedAt automatically
});
// Create: backend/routes/customAnalytics.js
const express = require('express');
const router = express.Router();
const Course = require('../models/Course');
const User = require('../models/User');
const auth = require('../authMiddleware');
// Custom analytics endpoint
router.get('/api/courses/:courseId/analytics/advanced', auth, async (req, res) => {
try {
const courseId = req.params.courseId;
const course = await Course.findById(courseId);
// Verify instructor ownership
if (course.instructor.toString() !== req.user.id) {
return res.status(403).json({ success: false, message: 'Access denied' });
}
// Get enrolled students
const enrolledStudents = await User.find({
'enrolledCourses.course': courseId
}).select('firstName lastName email enrolledCourses');
// Calculate advanced metrics
const analytics = {
basic: {
totalEnrolled: enrolledStudents.length,
totalCompleted: enrolledStudents.filter(s =>
s.enrolledCourses.find(ec =>
ec.course.equals(courseId) && ec.status === 'completed'
)
).length,
completionRate: enrolledStudents.length > 0 ?
(enrolledStudents.filter(s =>
s.enrolledCourses.find(ec =>
ec.course.equals(courseId) && ec.status === 'completed'
)
).length / enrolledStudents.length * 100).toFixed(1) : 0
},
engagement: {
avgProgress: enrolledStudents.reduce((sum, student) => {
const enrollment = student.enrolledCourses.find(ec => ec.course.equals(courseId));
return sum + (enrollment?.progress || 0);
}, 0) / (enrolledStudents.length || 1),
activeStudents: enrolledStudents.filter(s => {
const enrollment = s.enrolledCourses.find(ec => ec.course.equals(courseId));
return enrollment && enrollment.progress > 0 && enrollment.progress < 100;
}).length,
inactiveStudents: enrolledStudents.filter(s => {
const enrollment = s.enrolledCourses.find(ec => ec.course.equals(courseId));
return enrollment && enrollment.progress === 0;
}).length
},
timeline: {
enrollmentsByDay: await getEnrollmentsByDay(courseId),
completionsByDay: await getCompletionsByDay(courseId),
last30DaysActivity: await get30DayActivity(courseId)
},
content: {
mostViewedLessons: await getMostViewedLessons(courseId),
difficultQuizzes: await getQuizPerformance(courseId),
popularResources: await getResourceDownloads(courseId)
}
};
res.json({ success: true, analytics });
} catch (error) {
console.error('Analytics error:', error);
res.status(500).json({ success: false, message: 'Server error' });
}
});
// Helper functions
async function getEnrollmentsByDay(courseId) {
const users = await User.find({ 'enrolledCourses.course': courseId });
const enrollmentsByDay = {};
users.forEach(user => {
const enrollment = user.enrolledCourses.find(ec => ec.course.equals(courseId));
if (enrollment && enrollment.enrolledAt) {
const date = enrollment.enrolledAt.toISOString().split('T')[0];
enrollmentsByDay[date] = (enrollmentsByDay[date] || 0) + 1;
}
});
return enrollmentsByDay;
}
// Export router
module.exports = router;
// In server.js, add:
// app.use('/', require('./routes/customAnalytics'));
// Create: backend/middleware/rateLimit.js
const rateLimit = require('express-rate-limit');
// General API rate limiter
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // Limit each IP to 100 requests per windowMs
message: {
success: false,
message: 'Too many requests from this IP, please try again after 15 minutes'
},
standardHeaders: true,
legacyHeaders: false
});
// Course creation rate limiter (more restrictive)
const courseCreationLimiter = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 5, // Limit to 5 course creations per hour
message: {
success: false,
message: 'Too many course creations. Please try again later.'
},
skip: (req) => {
// Skip rate limiting for admins
return req.user && req.user.role === 'admin';
}
});
// Quiz submission rate limiter
const quizSubmissionLimiter = rateLimit({
windowMs: 5 * 60 * 1000, // 5 minutes
max: 10, // Limit to 10 quiz submissions per 5 minutes
message: {
success: false,
message: 'Too many quiz submissions. Please wait a few minutes.'
}
});
// File upload rate limiter
const fileUploadLimiter = rateLimit({
windowMs: 10 * 60 * 1000, // 10 minutes
max: 20, // Limit to 20 file uploads per 10 minutes
message: {
success: false,
message: 'Too many file uploads. Please try again later.'
}
});
module.exports = {
apiLimiter,
courseCreationLimiter,
quizSubmissionLimiter,
fileUploadLimiter
};
// Usage in server.js:
const { apiLimiter, courseCreationLimiter } = require('./middleware/rateLimit');
// Apply to specific routes
app.post('/api/instructor/courses', auth, courseCreationLimiter, thumbnailUpload.single('thumbnail'), async (req, res) => {
// ... existing code
});
app.post('/api/courses/:courseId/quizzes/:quizId/submit', auth, quizSubmissionLimiter, async (req, res) => {
// ... existing code
});
// Install required packages
// npm install @aws-sdk/client-s3 multer-s3
// Create: backend/config/s3Storage.js
const { S3Client } = require('@aws-sdk/client-s3');
const multerS3 = require('multer-s3');
const path = require('path');
// Configure S3 client
const s3Client = new S3Client({
region: process.env.AWS_REGION,
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY
}
});
// Create S3 storage engine for Multer
const s3Storage = multerS3({
s3: s3Client,
bucket: process.env.AWS_BUCKET_NAME,
acl: 'public-read',
contentType: multerS3.AUTO_CONTENT_TYPE,
metadata: function (req, file, cb) {
cb(null, { fieldName: file.fieldname });
},
key: function (req, file, cb) {
const folder = getFolderByFieldname(file.fieldname);
const filename = `${Date.now()}-${path.basename(file.originalname, path.extname(file.originalname))}${path.extname(file.originalname)}`;
cb(null, `${folder}/${filename}`);
}
});
// Helper function to determine S3 folder
function getFolderByFieldname(fieldname) {
const folderMap = {
'thumbnail': 'thumbnails',
'courseLogo': 'logos',
'videoFile': 'videos',
'exerciseFiles': 'exercise-files',
'avatar': 'avatars',
'coverPhoto': 'covers'
};
return folderMap[fieldname] || 'uploads';
}
// Update Multer configuration in server.js
// Replace local storage paths with S3:
// Update environment variables
AWS_REGION=us-east-1
AWS_ACCESS_KEY_ID=your_access_key
AWS_SECRET_ACCESS_KEY=your_secret_key
AWS_BUCKET_NAME=your-bucket-name
USE_S3_STORAGE=true // Flag to toggle between local and S3
// Utility function to get file URL
function getFileUrl(key) {
if (process.env.USE_S3_STORAGE === 'true') {
return `https://${process.env.AWS_BUCKET_NAME}.s3.${process.env.AWS_REGION}.amazonaws.com/${key}`;
} else {
return `/uploads/${key}`; // Local path (client/public/uploads)
}
}
// Update course save logic
if (req.file) {
if (process.env.USE_S3_STORAGE === 'true') {
course.thumbnail = getFileUrl(req.file.key); // S3 key
} else {
course.thumbnail = `uploads/thumbnails/${req.file.filename}`; // Local path
}
}
// Directory: backend/plugins/gamification/
// gamificationPlugin.js
class GamificationPlugin {
constructor() {
this.name = 'Gamification Plugin';
this.version = '1.0.0';
this.events = [];
}
initialize(app) {
console.log(`${this.name} v${this.version} initialized`);
// Add custom routes
app.get('/api/plugin/gamification/leaderboard', this.getLeaderboard.bind(this));
app.post('/api/plugin/gamification/award-points', this.awardPoints.bind(this));
// Listen to system events
this.registerEvents();
}
registerEvents() {
// Listen for lesson completion
courseEvents.on('lesson.completed', (data) => {
this.awardLessonCompletionPoints(data.userId, data.courseId);
});
// Listen for quiz completion
courseEvents.on('quiz.completed', (data) => {
this.awardQuizCompletionPoints(data.userId, data.score, data.courseId);
});
// Listen for course completion
courseEvents.on('course.completed', (data) => {
this.awardCourseCompletionBadge(data.userId, data.courseId);
});
}
async getLeaderboard(req, res) {
try {
// Calculate leaderboard based on points
const leaderboard = await this.calculateLeaderboard();
res.json({ success: true, leaderboard });
} catch (error) {
res.status(500).json({ success: false, message: error.message });
}
}
async awardPoints(req, res) {
try {
const { userId, points, reason } = req.body;
// Award points to user
await User.findByIdAndUpdate(userId, {
$inc: { 'gamification.points': points }
});
// Log the award
await this.logAward(userId, points, reason);
res.json({ success: true, message: 'Points awarded' });
} catch (error) {
res.status(500).json({ success: false, message: error.message });
}
}
async awardLessonCompletionPoints(userId, courseId) {
const points = 10; // Base points for lesson completion
await this.awardPointsToUser(userId, points, 'lesson_completion');
// Streak bonus
const streak = await this.checkStreak(userId);
if (streak >= 7) {
await this.awardPointsToUser(userId, 50, 'weekly_streak');
}
}
async awardQuizCompletionPoints(userId, score, courseId) {
let points = Math.floor(score / 10); // 1 point per 10% score
// Perfect score bonus
if (score === 100) {
points += 20;
await this.awardBadge(userId, 'perfectionist');
}
await this.awardPointsToUser(userId, points, 'quiz_completion');
}
async awardCourseCompletionBadge(userId, courseId) {
await this.awardBadge(userId, 'course_completion');
// Speed completion bonus (completed in < 7 days)
const completionTime = await this.getCompletionTime(userId, courseId);
if (completionTime < 7 * 24 * 60 * 60 * 1000) { // 7 days in milliseconds
await this.awardBadge(userId, 'fast_learner');
await this.awardPointsToUser(userId, 100, 'fast_course_completion');
}
}
// Helper methods
async awardPointsToUser(userId, points, reason) {
await User.findByIdAndUpdate(userId, {
$inc: { 'gamification.points': points },
$push: {
'gamification.history': {
points,
reason,
date: new Date()
}
}
});
}
async awardBadge(userId, badgeType) {
await User.findByIdAndUpdate(userId, {
$addToSet: { 'gamification.badges': badgeType }
});
}
async calculateLeaderboard() {
const users = await User.find({ 'gamification.points': { $gt: 0 } })
.select('firstName lastName gamification.points gamification.badges avatar')
.sort({ 'gamification.points': -1 })
.limit(50);
return users.map((user, index) => ({
rank: index + 1,
name: `${user.firstName} ${user.lastName}`,
points: user.gamification?.points || 0,
badges: user.gamification?.badges || [],
avatar: user.avatar
}));
}
}
// Extend User model
// In models/User.js, add:
gamification: {
points: { type: Number, default: 0 },
badges: [String],
level: { type: Number, default: 1 },
history: [{
points: Number,
reason: String,
date: { type: Date, default: Date.now }
}],
achievements: [{
name: String,
description: String,
earnedAt: Date,
icon: String
}]
}
// Initialize plugin in server.js
const GamificationPlugin = require('./plugins/gamification/gamificationPlugin');
const gamificationPlugin = new GamificationPlugin();
gamificationPlugin.initialize(app);
// Install: npm install redis
// Create: backend/middleware/cache.js
const redis = require('redis');
const { promisify } = require('util');
// Create Redis client
const redisClient = redis.createClient({
host: process.env.REDIS_HOST || 'localhost',
port: process.env.REDIS_PORT || 6379,
password: process.env.REDIS_PASSWORD
});
// Promisify Redis methods
const getAsync = promisify(redisClient.get).bind(redisClient);
const setexAsync = promisify(redisClient.setex).bind(redisClient);
const delAsync = promisify(redisClient.del).bind(redisClient);
// Cache middleware
function cacheMiddleware(duration = 300) { // 5 minutes default
return async (req, res, next) => {
// Only cache GET requests
if (req.method !== 'GET') {
return next();
}
// Generate cache key from request
const cacheKey = `cache:${req.originalUrl}`;
try {
// Try to get from cache
const cachedData = await getAsync(cacheKey);
if (cachedData) {
console.log('Cache hit:', cacheKey);
return res.json(JSON.parse(cachedData));
}
// Cache miss - override res.json to cache response
const originalJson = res.json;
res.json = function(data) {
// Cache successful responses
if (res.statusCode === 200 && data.success !== false) {
setexAsync(cacheKey, duration, JSON.stringify(data))
.catch(err => console.error('Redis set error:', err));
}
// Call original function
return originalJson.call(this, data);
};
next();
} catch (error) {
console.error('Cache error:', error);
next(); // Continue without caching on error
}
};
}
// Cache invalidation on updates
function invalidateCache(patterns) {
return async (req, res, next) => {
// Store original send function
const originalSend = res.send;
res.send = async function(data) {
// Invalidate cache after successful update
if (res.statusCode >= 200 && res.statusCode < 300) {
try {
// Get all keys matching patterns
const keys = patterns.map(pattern => `cache:${pattern}`);
await delAsync(keys);
console.log('Cache invalidated for patterns:', patterns);
} catch (error) {
console.error('Cache invalidation error:', error);
}
}
return originalSend.call(this, data);
};
next();
};
}
// Usage in routes
app.get('/api/courses', cacheMiddleware(600), async (req, res) => {
// This will be cached for 10 minutes
const courses = await Course.find({ status: 'Published' });
res.json({ success: true, courses });
});
app.put('/api/courses/:id', auth, invalidateCache(['/api/courses/*', '/api/courses']), async (req, res) => {
// This will invalidate course cache when course is updated
// ... update logic
});
// 1. Indexing for Performance
// In models/Course.js
CourseSchema.index({ status: 1, createdAt: -1 }); // For listing published courses
CourseSchema.index({ instructor: 1, status: 1 }); // For instructor dashboard
CourseSchema.index({ slug: 1 }, { unique: true }); // Already exists
CourseSchema.index({ 'episodes.lessons._id': 1 }); // For lesson queries
CourseSchema.index({ 'episodes.quizzes._id': 1 }); // For quiz queries
// In models/User.js
UserSchema.index({ email: 1 }, { unique: true });
UserSchema.index({ role: 1 });
UserSchema.index({ 'enrolledCourses.course': 1 }); // For student enrollment queries
UserSchema.index({ 'wishlist': 1 }); // For wishlist queries
// In models/Review.js
ReviewSchema.index({ course: 1, createdAt: -1 });
ReviewSchema.index({ user: 1, course: 1 }, { unique: true }); // One review per user per course
// 2. Efficient Population Strategies
// Bad: N+1 query problem
const courses = await Course.find();
for (let course of courses) {
const instructor = await User.findById(course.instructor); // Additional query per course!
}
// Good: Single query with population
const courses = await Course.find()
.populate('instructor', 'firstName lastName avatar') // Only needed fields
.select('title thumbnail price instructor') // Only needed fields
.limit(20) // Pagination
.lean(); // Returns plain JavaScript objects (faster)
// 3. Aggregation for Complex Queries
async function getInstructorStats(instructorId) {
const stats = await Course.aggregate([
{ $match: { instructor: mongoose.Types.ObjectId(instructorId) } },
{
$lookup: {
from: 'users',
localField: '_id',
foreignField: 'enrolledCourses.course',
as: 'enrolledStudents'
}
},
{
$group: {
_id: '$instructor',
totalCourses: { $sum: 1 },
totalStudents: { $sum: { $size: '$enrolledStudents' } },
averagePrice: { $avg: '$price' },
totalRevenue: { $sum: '$price' }
}
}
]);
return stats[0];
}
// 4. Connection Pooling
// In your db connection file (backend/config/db.js)
mongoose.connect(process.env.MONGO_URI, {
maxPoolSize: 10, // Maximum number of connections in pool
minPoolSize: 5, // Minimum number of connections
socketTimeoutMS: 45000, // Close sockets after 45 seconds of inactivity
family: 4 // Use IPv4, skip trying IPv6
});
// 5. Query Optimization Tips
// Use projection to select only needed fields
const course = await Course.findById(courseId)
.select('title slug thumbnail price episodes')
.populate({
path: 'episodes.lessons',
select: 'title duration lessonType' // Only needed lesson fields
});
// Use $facet for multiple aggregations in one query
const analytics = await Course.aggregate([
{ $match: { instructor: instructorId } },
{
$facet: {
priceStats: [
{ $group: {
_id: null,
avgPrice: { $avg: '$price' },
maxPrice: { $max: '$price' },
minPrice: { $min: '$price' }
}}
],
statusCounts: [
{ $group: {
_id: '$status',
count: { $sum: 1 }
}}
],
recentCourses: [
{ $sort: { createdAt: -1 } },
{ $limit: 5 }
]
}
}
]);
// 6. Bulk Operations
// Instead of multiple updates, use bulkWrite
await Course.bulkWrite([
{
updateOne: {
filter: { _id: courseId1 },
update: { $set: { status: 'Published' } }
}
},
{
updateOne: {
filter: { _id: courseId2 },
update: { $set: { status: 'Published' } }
}
}
// ... more operations
]);
// 7. Text Search Optimization
CourseSchema.index({
title: 'text',
description: 'text',
'episodes.title': 'text',
'episodes.summary': 'text'
});
const searchResults = await Course.find(
{ $text: { $search: searchQuery } },
{ score: { $meta: 'textScore' } }
).sort({ score: { $meta: 'textScore' } });
// 1. Lazy Loading Images
// In your HTML
<img data-src="image.jpg" class="lazy" alt="...">
// JavaScript
document.addEventListener("DOMContentLoaded", function() {
const lazyImages = [].slice.call(document.querySelectorAll("img.lazy"));
if ("IntersectionObserver" in window) {
const lazyImageObserver = new IntersectionObserver(function(entries) {
entries.forEach(function(entry) {
if (entry.isIntersecting) {
const lazyImage = entry.target;
lazyImage.src = lazyImage.dataset.src;
lazyImage.classList.remove("lazy");
lazyImageObserver.unobserve(lazyImage);
}
});
});
lazyImages.forEach(function(lazyImage) {
lazyImageObserver.observe(lazyImage);
});
}
});
// 2. Code Splitting for Course Builder
// Create separate bundle for course builder
// In your build configuration or using dynamic imports
// Dynamic import for heavy modules
async function loadCourseBuilder() {
if (!window.courseBuilderLoaded) {
const module = await import('./modules/course-builder-heavy.js');
window.courseBuilderLoaded = true;
return module;
}
}
// 3. Debouncing Search Input
let searchTimeout;
document.getElementById('course-search').addEventListener('input', function(e) {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
searchCourses(e.target.value);
}, 300); // Wait 300ms after user stops typing
});
// 4. Virtual Scrolling for Long Lists
class VirtualScroll {
constructor(container, items, itemHeight, renderItem) {
this.container = container;
this.items = items;
this.itemHeight = itemHeight;
this.renderItem = renderItem;
this.visibleItems = Math.ceil(container.clientHeight / itemHeight);
this.container.style.overflowY = 'auto';
this.container.style.height = `${this.visibleItems * itemHeight}px`;
this.render();
this.container.addEventListener('scroll', this.handleScroll.bind(this));
}
render() {
const scrollTop = this.container.scrollTop;
const startIndex = Math.floor(scrollTop / this.itemHeight);
const endIndex = startIndex + this.visibleItems;
const visibleItems = this.items.slice(startIndex, endIndex);
this.container.innerHTML = '';
visibleItems.forEach((item, index) => {
const itemElement = this.renderItem(item);
itemElement.style.position = 'absolute';
itemElement.style.top = `${(startIndex + index) * this.itemHeight}px`;
this.container.appendChild(itemElement);
});
}
handleScroll() {
this.render();
}
}
// 5. Memoization for Expensive Functions
const memoize = (fn) => {
const cache = {};
return (...args) => {
const key = JSON.stringify(args);
if (cache[key]) {
return cache[key];
}
const result = fn(...args);
cache[key] = result;
return result;
};
};
// Expensive calculation function
const calculateCourseStats = memoize((courseId) => {
// Complex calculations here
return {
// ... calculated stats
};
});
// 6. Web Workers for Heavy Processing
// main.js
const worker = new Worker('worker.js');
worker.postMessage({
type: 'processCourses',
data: largeCourseArray
});
worker.onmessage = function(e) {
const processedData = e.data;
// Update UI with processed data
};
// worker.js
self.onmessage = function(e) {
if (e.data.type === 'processCourses') {
const processed = processCourseData(e.data.data);
self.postMessage(processed);
}
};
function processCourseData(courses) {
// Heavy processing that would block main thread
return courses.map(course => ({
...course,
calculatedStats: complexCalculation(course)
}));
}
// 7. Bundle Optimization
// Use tools like:
// - Webpack for bundling
// - Babel for transpiling
// - Terser for minification
// - PurgeCSS for removing unused CSS
// Example webpack.config.js for production
module.exports = {
mode: 'production',
optimization: {
minimize: true,
minimizer: [new TerserPlugin()],
splitChunks: {
chunks: 'all',
cacheGroups: {
vendor: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
chunks: 'all'
}
}
}
}
};
// 1. Image Compression Strategy
// Recommended image sizes and formats:
/*
Course Thumbnails: 700x430px, WebP (fallback JPG), ≤ 100KB
User Avatars: 200x200px, WebP, ≤ 50KB
Course Logos: 500x500px, PNG (transparent), ≤ 150KB
Lesson Images: Max width 1200px, WebP, ≤ 200KB
Background Images: 1920x1080px, JPG (progressive), ≤ 300KB
*/
// 2. Using Sharp for Server-Side Image Processing
// npm install sharp
const sharp = require('sharp');
async function optimizeImage(inputPath, outputPath, options) {
await sharp(inputPath)
.resize(options.width, options.height, {
fit: 'cover',
position: 'center'
})
.toFormat(options.format, {
quality: options.quality || 80,
progressive: true
})
.toFile(outputPath);
}
// Usage in upload handler
const optimizedPath = `uploads/thumbnails/optimized-${Date.now()}.webp`;
await optimizeImage(req.file.path, optimizedPath, {
width: 700,
height: 430,
format: 'webp',
quality: 85
});
// 3. Responsive Images with srcset
<img
src="thumbnail-700.webp"
srcset="thumbnail-350.webp 350w,
thumbnail-700.webp 700w,
thumbnail-1400.webp 1400w"
sizes="(max-width: 768px) 100vw, 700px"
alt="Course thumbnail"
>
// 4. Font Optimization
/* Use font-display: swap for custom fonts */
@font-face {
font-family: 'CustomFont';
src: url('font.woff2') format('woff2'),
url('font.woff') format('woff');
font-display: swap; /* Shows fallback font first */
font-weight: 400;
font-style: normal;
}
// 5. CSS Optimization
// SCSS to optimized CSS workflow
/*
1. Development: SCSS with source maps
2. Build: Compress, autoprefix, remove unused
3. Production: Minified, critical CSS inlined
*/
// package.json scripts
"scripts": {
"scss:dev": "sass scss/main.scss static/assets/css/main.css --source-map",
"scss:build": "sass scss/main.scss static/assets/css/main.css --style=compressed",
"css:optimize": "postcss static/assets/css/main.css --use autoprefixer cssnano -o static/assets/css/main.min.css",
"purge:css": "purgecss --css static/assets/css/main.css --content '**/*.html' '**/*.js' --output static/assets/css/"
}
// 6. JavaScript Bundle Analysis
// Install: npm install webpack-bundle-analyzer
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
module.exports = {
plugins: [
new BundleAnalyzerPlugin({
analyzerMode: 'static',
reportFilename: 'bundle-analysis.html',
openAnalyzer: false
})
]
};
// 7. Service Worker for Offline Support
// sw.js
const CACHE_NAME = 'trulern-v1';
const urlsToCache = [
'/',
'/index.html',
'/assets/css/main.min.css',
'/assets/js/main.min.js',
'/assets/images/logo.png'
];
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => cache.addAll(urlsToCache))
);
});
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request)
.then(response => {
if (response) {
return response;
}
return fetch(event.request);
})
);
});
// 8. GZIP Compression
// In server.js
const compression = require('compression');
app.use(compression({
level: 6, // Compression level (0-9)
threshold: 0, // Compress all responses
filter: (req, res) => {
if (req.headers['x-no-compression']) {
return false;
}
return compression.filter(req, res);
}
}));
// 1. Helmet.js for Security Headers
const helmet = require('helmet');
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'", "'unsafe-inline'", "'unsafe-eval'"],
connectSrc: ["'self'", "https://api.razorpay.com"]
}
},
hsts: {
maxAge: 31536000,
includeSubDomains: true,
preload: true
},
referrerPolicy: { policy: 'strict-origin-when-cross-origin' }
}));
// 2. Rate Limiting by User ID
const rateLimit = require('express-rate-limit');
const RedisStore = require('rate-limit-redis');
const userRateLimiter = rateLimit({
store: new RedisStore({
prefix: 'rl_user:'
}),
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // limit each user to 100 requests per windowMs
keyGenerator: (req) => {
return req.user ? req.user.id : req.ip; // User-based or IP-based
},
message: 'Too many requests from this user, please try again later.'
});
// 3. Input Sanitization
const sanitizeHtml = require('sanitize-html');
const validator = require('validator');
function sanitizeUserInput(input) {
// Remove HTML tags
const sanitized = sanitizeHtml(input, {
allowedTags: [], // No HTML allowed
allowedAttributes: {}
});
// Validate email
if (input.includes('@')) {
return validator.normalizeEmail(sanitized);
}
// Validate URL
if (input.startsWith('http')) {
return validator.normalizeUrl(sanitized);
}
return sanitized;
}
// 4. File Upload Security
const multer = require('multer');
const path = require('path');
const upload = multer({
storage: multer.memoryStorage(), // Process in memory first
limits: {
fileSize: 5 * 1024 * 1024, // 5MB max
files: 1
},
fileFilter: (req, file, cb) => {
// Check file type
const allowedTypes = /jpeg|jpg|png|gif|webp/;
const extname = allowedTypes.test(path.extname(file.originalname).toLowerCase());
const mimetype = allowedTypes.test(file.mimetype);
if (extname && mimetype) {
// Additional security check
checkFileForMalware(file.buffer)
.then(isSafe => {
if (isSafe) {
cb(null, true);
} else {
cb(new Error('File security check failed'));
}
})
.catch(err => cb(err));
} else {
cb(new Error('Only image files are allowed'));
}
}
});
// 5. JWT Security Enhancement
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
// Generate secure JWT secret
const generateJWTSecret = () => {
return crypto.randomBytes(64).toString('hex');
};
// Enhanced JWT options
const jwtOptions = {
expiresIn: '5h',
issuer: 'trulern-lms',
audience: 'trulern-users',
jwtid: crypto.randomBytes(16).toString('hex') // Unique JWT ID
};
// Token blacklisting
const tokenBlacklist = new Set();
const blacklistToken = (token) => {
const decoded = jwt.decode(token);
if (decoded && decoded.exp) {
// Store until token expires
const ttl = decoded.exp - Math.floor(Date.now() / 1000);
if (ttl > 0) {
tokenBlacklist.add(token);
setTimeout(() => tokenBlacklist.delete(token), ttl * 1000);
}
}
};
// 6. Password Security
const bcrypt = require('bcryptjs');
const zxcvbn = require('zxcvbn');
function validatePasswordStrength(password) {
const result = zxcvbn(password);
if (result.score < 3) {
return {
valid: false,
score: result.score,
warning: result.feedback.warning,
suggestions: result.feedback.suggestions
};
}
return {
valid: true,
score: result.score
};
}
async function hashPassword(password) {
const saltRounds = 12; // Increased from default 10
return await bcrypt.hash(password, saltRounds);
}
// 7. Session Management
const session = require('express-session');
const MongoStore = require('connect-mongo');
app.use(session({
secret: process.env.SESSION_SECRET || generateJWTSecret(),
resave: false,
saveUninitialized: false,
store: MongoStore.create({
mongoUrl: process.env.MONGO_URI,
ttl: 14 * 24 * 60 * 60, // 14 days
autoRemove: 'native'
}),
cookie: {
secure: process.env.NODE_ENV === 'production',
httpOnly: true,
sameSite: 'strict',
maxAge: 14 * 24 * 60 * 60 * 1000 // 14 days
}
}));
// 8. SQL/NoSQL Injection Prevention
// Mongoose already prevents NoSQL injection, but add additional validation
const mongoSanitize = require('express-mongo-sanitize');
app.use(mongoSanitize({
replaceWith: '_',
onSanitize: ({ req, key }) => {
console.warn(`Attempted NoSQL injection: ${key}`, req.ip);
}
}));
// 9. API Key Security
const apiKeys = new Map();
function generateAPIKey() {
return crypto.randomBytes(32).toString('hex');
}
function validateAPIKey(req, res, next) {
const apiKey = req.headers['x-api-key'];
if (!apiKey) {
return res.status(401).json({ error: 'API key required' });
}
if (!apiKeys.has(apiKey)) {
return res.status(403).json({ error: 'Invalid API key' });
}
const keyData = apiKeys.get(apiKey);
// Check rate limits
if (keyData.requests > keyData.limit) {
return res.status(429).json({ error: 'Rate limit exceeded' });
}
// Update request count
keyData.requests++;
apiKeys.set(apiKey, keyData);
next();
}
// 10. Logging and Monitoring
const winston = require('winston');
const { Logtail } = require('@logtail/node');
const { LogtailTransport } = require('@logtail/winston');
const logger = winston.createLogger({
level: 'info',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.json()
),
transports: [
new winston.transports.File({ filename: 'error.log', level: 'error' }),
new winston.transports.File({ filename: 'combined.log' }),
new LogtailTransport(new Logtail(process.env.LOGTAIL_SOURCE_TOKEN))
]
});
// Security event logging
function logSecurityEvent(event, details) {
logger.warn('SECURITY_EVENT', {
event,
...details,
timestamp: new Date().toISOString(),
ip: req.ip,
userAgent: req.headers['user-agent']
});
}
TruLern LMS provides a comprehensive REST API that follows RESTful principles. All API endpoints return JSON responses and use HTTP status codes appropriately.
/api/. The base URL depends on your deployment: https://yourdomain.com/api/
or http://localhost:5000/api/ for development.
JWT-based authentication with role-based access control
Input validation with meaningful error messages
// Success Response
{
"success": true,
"message": "Operation completed successfully",
"data": { /* Response data */ },
"timestamp": "2024-01-15T10:30:00.000Z"
}
// Error Response
{
"success": false,
"message": "Descriptive error message",
"error": "Detailed error information",
"timestamp": "2024-01-15T10:30:00.000Z",
"code": "ERROR_CODE" // Optional error code
}
// Paginated Response
{
"success": true,
"data": [ /* Array of items */ ],
"pagination": {
"total": 150,
"page": 1,
"limit": 10,
"pages": 15,
"hasNext": true,
"hasPrev": false
}
}
| Code | Description | Usage |
|---|---|---|
| 200 | OK | Successful GET, PUT, DELETE requests |
| 201 | Created | Resource created successfully (POST) |
| 204 | No Content | Successful request with no body to return |
| 400 | Bad Request | Invalid request parameters or body |
| 401 | Unauthorized | Missing or invalid authentication |
| 403 | Forbidden | Authenticated but insufficient permissions |
| 404 | Not Found | Resource doesn't exist |
| 409 | Conflict | Resource conflict (duplicate, constraint violation) |
| 422 | Unprocessable Entity | Validation errors in request body |
| 429 | Too Many Requests | Rate limit exceeded |
| 500 | Internal Server Error | Server-side error |
// Current version: v1
// All endpoints are implicitly v1
// Example: /api/courses = /api/v1/courses
// Future versioning options:
// 1. URL Path Versioning
// /api/v2/courses
// /api/v2/users
// 2. Header Versioning
// Accept: application/vnd.trulern.v2+json
// 3. Query Parameter Versioning
// /api/courses?version=2
// Recommended: URL Path Versioning for clarity
app.use('/api/v1', require('./routes/v1'));
app.use('/api/v2', require('./routes/v2'));
// Response headers include rate limit information
X-RateLimit-Limit: 100 // Requests per window
X-RateLimit-Remaining: 95 // Remaining requests
X-RateLimit-Reset: 1704038400 // Unix timestamp when limit resets
Retry-After: 900 // Seconds to wait (if limited)
// Different limits for different endpoints
const rateLimits = {
public: {
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100 // 100 requests per 15 minutes
},
authenticated: {
windowMs: 15 * 60 * 1000,
max: 500 // 500 requests per 15 minutes
},
instructor: {
windowMs: 15 * 60 * 1000,
max: 1000 // 1000 requests per 15 minutes
},
payment: {
windowMs: 60 * 1000, // 1 minute
max: 10 // 10 payment requests per minute
}
};
| Error Code | HTTP Status | Description | Possible Causes |
|---|---|---|---|
AUTH_001 |
401 | Invalid credentials | Wrong email/password, expired token |
AUTH_002 |
403 | Insufficient permissions | Student trying to access instructor endpoints |
VALIDATION_001 |
400 | Validation failed | Missing required fields, invalid format |
COURSE_001 |
404 | Course not found | Invalid course ID, deleted course |
PAYMENT_001 |
400 | Payment failed | Insufficient funds, declined card |
DB_001 |
500 | Database error | Connection issue, query timeout |
FILE_001 |
400 | File upload failed | Invalid file type, size too large |
RATE_LIMIT_001 |
429 | Rate limit exceeded | Too many requests in short time |
The authentication system uses JWT (JSON Web Tokens) for stateless authentication.
Tokens expire after 5 hours and must be included in the x-auth-token header for protected
endpoints.
User creates account with email and password. Password is hashed using bcrypt before storage.
User provides credentials, server validates and returns JWT token.
Client includes JWT in x-auth-token header for all subsequent requests.
Middleware validates token on each request, extracts user data, and checks permissions.
| Method | Endpoint | Description | Auth Required |
|---|---|---|---|
| POST | /api/register |
Register as student | No |
| POST | /api/register-instructor |
Register as instructor | No |
| POST | /api/login |
Login and get JWT token | No |
| GET | /api/user/profile |
Get current user profile | Yes |
| PUT | /api/user/profile |
Update user profile | Yes |
| POST | /api/forgot-password |
Request password reset email | No |
| POST | /api/reset-password |
Reset password with token | No |
Register a new student account.
{
"firstName": "John",
"lastName": "Doe",
"email": "john@example.com",
"password": "SecurePass123!"
}
// Response (Success - 201 Created)
{
"success": true,
"message": "User registered successfully!",
"user": {
"id": "507f1f77bcf86cd799439011",
"firstName": "John",
"lastName": "Doe",
"email": "john@example.com",
"role": "student",
"createdAt": "2024-01-15T10:30:00.000Z"
}
}
// Response (Error - 400 Bad Request)
{
"success": false,
"message": "User already exists",
"error": "Email is already registered"
}
Authenticate user and receive JWT token.
{
"username": "john@example.com",
"password": "SecurePass123!"
}
// Response (Success - 200 OK)
{
"success": true,
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"user": {
"id": "507f1f77bcf86cd799439011",
"role": "student",
"name": "John Doe",
"avatar": "uploads/avatars/default.jpg"
}
}
// Decoded JWT Payload
{
"user": {
"id": "507f1f77bcf86cd799439011",
"role": "student",
"name": "John Doe",
"avatar": "uploads/avatars/default.jpg"
},
"iat": 1704038400,
"exp": 1704056400
}
// Response (Error - 400 Bad Request)
{
"success": false,
"message": "Invalid Credentials"
}
x-auth-token header for all authenticated requests. The token expires after 5 hours.
Two-step process for password recovery.
// POST /api/forgot-password
{
"email": "john@example.com"
}
// Response (Always returns 200 to prevent email enumeration)
{
"success": true,
"message": "If a matching account was found, a password reset email has been sent."
}
// Email contains link like:
// http://localhost:5000/reset-password.html?token=reset_token_here
// POST /api/reset-password
{
"token": "reset_token_from_email",
"newPassword": "NewSecurePass456!",
"confirmNewPassword": "NewSecurePass456!"
}
// Response (Success - 200 OK)
{
"success": true,
"message": "Password has been successfully reset. You can now log in."
}
// Response (Error - 400 Bad Request)
{
"success": false,
"message": "Password reset link is invalid or has expired. Please try again."
}
const jwt = require('jsonwebtoken');
const User = require('./models/User');
module.exports = async function(req, res, next) {
// Get token from header
const token = req.header('x-auth-token');
// Check if no token
if (!token) {
return res.status(401).json({
success: false,
message: 'No token, authorization denied'
});
}
try {
// Verify token
const decoded = jwt.verify(token, process.env.JWT_SECRET);
// Check if user still exists
const user = await User.findById(decoded.user.id).select('-password');
if (!user) {
return res.status(401).json({
success: false,
message: 'User no longer exists'
});
}
// Check if user is active (optional)
if (user.status === 'suspended') {
return res.status(403).json({
success: false,
message: 'Account is suspended'
});
}
// Add user to request object
req.user = decoded.user;
next();
} catch (err) {
console.error('Auth middleware error:', err.message);
if (err.name === 'TokenExpiredError') {
return res.status(401).json({
success: false,
message: 'Token has expired. Please log in again.'
});
}
if (err.name === 'JsonWebTokenError') {
return res.status(401).json({
success: false,
message: 'Invalid token. Please log in again.'
});
}
res.status(500).json({
success: false,
message: 'Server error during authentication'
});
}
};
// In server.js or separate middleware file
const isInstructor = (req, res, next) => {
if (req.user && req.user.role === 'instructor') {
return next();
}
return res.status(403).json({
success: false,
message: 'Access denied. Instructor role required.'
});
};
// Usage
app.get('/api/instructor/dashboard', auth, isInstructor, async (req, res) => {
// Only instructors can access this
});
// Student middleware (optional)
const isStudent = (req, res, next) => {
if (req.user && req.user.role === 'student') {
return next();
}
return res.status(403).json({
success: false,
message: 'Access denied. Student role required.'
});
};
// Admin middleware
const isAdmin = (req, res, next) => {
if (req.user && req.user.role === 'admin') {
return next();
}
return res.status(403).json({
success: false,
message: 'Access denied. Admin role required.'
});
};
// Course ownership middleware
const isCourseOwner = async (req, res, next) => {
try {
const course = await Course.findById(req.params.courseId);
if (!course) {
return res.status(404).json({
success: false,
message: 'Course not found'
});
}
if (course.instructor.toString() !== req.user.id) {
return res.status(403).json({
success: false,
message: 'User not authorized to access this course'
});
}
next();
} catch (error) {
res.status(500).json({
success: false,
message: 'Server error'
});
}
};
import React, { createContext, useContext, useState, useEffect } from 'react';
const AuthContext = createContext();
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within AuthProvider');
}
return context;
};
export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null);
const [token, setToken] = useState(null);
const [loading, setLoading] = useState(true);
// Check for existing session on mount
useEffect(() => {
const storedToken = localStorage.getItem('lmsToken');
const storedUser = localStorage.getItem('lmsUser');
if (storedToken && storedUser) {
setToken(storedToken);
setUser(JSON.parse(storedUser));
}
setLoading(false);
}, []);
// Login function
const login = async (credentials) => {
try {
const response = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credentials)
});
const data = await response.json();
if (data.success) {
// Store in localStorage
localStorage.setItem('lmsToken', data.token);
localStorage.setItem('lmsUser', JSON.stringify(data.user));
// Update state
setToken(data.token);
setUser(data.user);
return { success: true, user: data.user };
} else {
return { success: false, message: data.message };
}
} catch (error) {
console.error('Login error:', error);
return { success: false, message: 'Network error' };
}
};
// Register function
const register = async (userData) => {
try {
const response = await fetch('/api/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(userData)
});
return await response.json();
} catch (error) {
console.error('Registration error:', error);
return { success: false, message: 'Network error' };
}
};
// Logout function
const logout = () => {
localStorage.removeItem('lmsToken');
localStorage.removeItem('lmsUser');
setToken(null);
setUser(null);
};
// Check if user has specific role
const isInstructor = () => user?.role === 'instructor';
const isStudent = () => user?.role === 'student';
const isAdmin = () => user?.role === 'admin';
// Fetch with auth header
const fetchWithAuth = async (url, options = {}) => {
if (!token) {
throw new Error('No authentication token');
}
const response = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
'x-auth-token': token,
...options.headers
}
});
// Handle token expiration
if (response.status === 401) {
const data = await response.json().catch(() => ({}));
if (data.message?.includes('expired')) {
logout();
throw new Error('Session expired. Please log in again.');
}
}
return response;
};
// Password reset functions
const requestPasswordReset = async (email) => {
try {
const response = await fetch('/api/forgot-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email })
});
return await response.json();
} catch (error) {
console.error('Password reset request error:', error);
return { success: false, message: 'Network error' };
}
};
const resetPassword = async (token, newPassword, confirmPassword) => {
try {
const response = await fetch('/api/reset-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token,
newPassword,
confirmNewPassword: confirmPassword
})
});
return await response.json();
} catch (error) {
console.error('Password reset error:', error);
return { success: false, message: 'Network error' };
}
};
const value = {
user,
token,
loading,
login,
register,
logout,
isInstructor,
isStudent,
isAdmin,
fetchWithAuth,
requestPasswordReset,
resetPassword
};
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
};
// src/pages/Login.jsx
import React, { useState } from 'react';
import { useAuth } from '../context/AuthContext';
import { useNavigate } from 'react-router-dom';
const LoginPage = () => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const { login } = useAuth();
const navigate = useNavigate();
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
setError('');
const result = await login({ username: email, password });
if (result.success) {
// Redirect based on role
if (result.user.role === 'instructor') {
navigate('/instructor/dashboard');
} else if (result.user.role === 'admin') {
navigate('/admin/dashboard');
} else {
navigate('/student/dashboard');
}
} else {
setError(result.message);
}
setLoading(false);
};
return (
<form onSubmit={handleSubmit}>
{error && <div className="alert alert-danger">{error}</div>}
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Email"
required
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Password"
required
/>
<button type="submit" disabled={loading}>
{loading ? 'Logging in...' : 'Login'}
</button>
</form>
);
};
export default LoginPage;
// src/components/PrivateRoute.jsx
import React from 'react';
import { Navigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
const PrivateRoute = ({ children, allowedRoles = [] }) => {
const { user, loading } = useAuth();
if (loading) {
return <div>Loading...</div>;
}
if (!user) {
return <Navigate to="/login" replace />;
}
if (allowedRoles.length > 0 && !allowedRoles.includes(user.role)) {
return <Navigate to="/dashboard" replace />;
}
return children;
};
export default PrivateRoute;
// Usage in App.jsx
<Route path="/instructor/dashboard" element={
<PrivateRoute allowedRoles={['instructor']}>
<InstructorDashboard />
</PrivateRoute>
} />
Symptoms: Getting 401 errors after some time of inactivity.
Solution: Implement automatic token refresh or redirect to login.
// In AuthContext.jsx - Add token refresh
import { useCallback, useEffect } from 'react';
const TOKEN_LIFETIME = 5 * 60 * 60 * 1000; // 5 hours
const REFRESH_BUFFER = 5 * 60 * 1000; // 5 minutes before expiry
export const AuthProvider = ({ children }) => {
// ... existing state
const refreshToken = useCallback(async () => {
try {
const response = await fetch('/api/refresh-token', {
method: 'POST',
headers: { 'x-auth-token': token }
});
if (response.ok) {
const data = await response.json();
localStorage.setItem('lmsToken', data.token);
setToken(data.token);
localStorage.setItem('tokenTimestamp', Date.now());
} else {
// Refresh failed, logout
logout();
}
} catch (error) {
console.error('Token refresh failed:', error);
logout();
}
}, [token, logout]);
useEffect(() => {
if (!token || !user) return;
const tokenTimestamp = localStorage.getItem('tokenTimestamp');
if (!tokenTimestamp) return;
const tokenAge = Date.now() - parseInt(tokenTimestamp);
if (tokenAge > TOKEN_LIFETIME - REFRESH_BUFFER) {
refreshToken();
}
// Set up periodic check
const interval = setInterval(() => {
const currentAge = Date.now() - parseInt(localStorage.getItem('tokenTimestamp') || 0);
if (currentAge > TOKEN_LIFETIME - REFRESH_BUFFER) {
refreshToken();
}
}, 60 * 1000); // Check every minute
return () => clearInterval(interval);
}, [token, user, refreshToken]);
// ... rest of provider
};
Symptoms: Authentication works in development but fails in production with CORS errors.
Solution: Configure CORS properly in server.js.
// In server.js
const cors = require('cors');
const allowedOrigins = [
'http://localhost:3000',
'http://localhost:5000',
'https://yourdomain.com',
'https://www.yourdomain.com'
];
const corsOptions = {
origin: function (origin, callback) {
// Allow requests with no origin (like mobile apps or curl requests)
if (!origin) return callback(null, true);
if (allowedOrigins.indexOf(origin) === -1) {
const msg = 'The CORS policy for this site does not allow access from the specified Origin.';
return callback(new Error(msg), false);
}
return callback(null, true);
},
credentials: true, // Allow cookies if needed
exposedHeaders: ['x-auth-token'] // Expose custom headers
};
app.use(cors(corsOptions));
// For preflight requests
app.options('*', cors(corsOptions));
The Course Management API provides comprehensive endpoints for creating, reading, updating, and deleting courses. This includes curriculum management, lesson content, quizzes, and student enrollment.
| Method | Endpoint | Description | Auth Required |
|---|---|---|---|
| GET | /api/courses |
Get all published courses (with filters) | No |
| GET | /api/courses/:id |
Get single course details | No |
| GET | /api/courses/edit/:id |
Get course for editing | Yes (Instructor) |
| POST | /api/instructor/courses |
Create new course (Step 1) | Yes (Instructor) |
| PUT | /api/courses/:courseId |
Update course details | Yes (Instructor/Owner) |
| DELETE | /api/courses/:courseId |
Delete course | Yes (Instructor/Owner) |
| GET | /api/courses/:courseId/enrollment-status |
Check student enrollment | Yes (Student) |
| GET | /api/instructor/my-courses-status |
Get instructor's courses with stats | Yes (Instructor) |
Retrieve published courses with advanced filtering and pagination.
// Available query parameters:
?search=web+development // Search in title/description
?school=School+of+Code // Filter by school
?categories=Web+Development,JavaScript // Filter by categories (comma-separated)
?price=free // Show only free courses
?price=paid // Show only paid courses
?minPrice=0&maxPrice=100 // Price range filter
?sortBy=latest // Sort options: latest, price_asc, price_desc
?limit=20 // Limit results per page
?page=2 // Pagination (requires frontend calculation)
// Example Request
GET /api/courses?search=react&categories=Web+Development&sortBy=latest&limit=10
// Response (Success - 200 OK)
{
"success": true,
"courses": [
{
"_id": "67a1b2c3d4e5f67890123456",
"title": "Complete React Developer Course",
"slug": "complete-react-developer-course",
"description": "Learn React from scratch...",
"instructor": {
"_id": "67a1b2c3d4e5f67890123457",
"firstName": "John",
"lastName": "Doe",
"avatar": "uploads/avatars/john.jpg"
},
"thumbnail": "uploads/thumbnails/react-course.jpg",
"price": 49.99,
"originalPrice": 99.99,
"difficultyLevel": "Intermediate",
"averageRating": 4.5,
"reviewCount": 128,
"enrolledCount": 1542,
"duration": {
"hours": 25,
"minutes": 30
},
"status": "Published",
"createdAt": "2024-01-10T10:30:00.000Z"
}
// ... more courses
],
"pagination": {
"totalCourses": 150
}
}
Get detailed information about a specific course, including curriculum and instructor info.
GET /api/courses/67a1b2c3d4e5f67890123456
// Response (Success - 200 OK)
{
"success": true,
"course": {
"_id": "67a1b2c3d4e5f67890123456",
"title": "Complete React Developer Course",
"slug": "complete-react-developer-course",
"description": "Learn React from scratch...",
"instructor": {
"_id": "67a1b2c3d4e5f67890123457",
"firstName": "John",
"lastName": "Doe",
"avatar": "uploads/avatars/john.jpg",
"occupation": "Senior Frontend Developer",
"bio": "10+ years of experience...",
"social": {
"website": "https://johndoe.dev",
"twitter": "johndoe",
"linkedin": "johndoe"
}
},
"thumbnail": "uploads/thumbnails/react-course.jpg",
"courseLogo": "uploads/logos/react-logo.png",
"price": 49.99,
"originalPrice": 99.99,
"previewVideoUrl": "https://vimeo.com/123456789",
"difficultyLevel": "Intermediate",
"maxStudents": 0, // 0 = unlimited
"isPublic": false,
"isQAEnabled": true,
"isMasterclass": false,
"school": "School of Code & Development",
"categories": ["Web Development", "JavaScript", "React"],
"language": ["English", "Spanish"],
"requirements": ["Basic HTML/CSS knowledge", "JavaScript fundamentals"],
"whatYoullLearn": ["Build React applications", "State management", "React hooks"],
"targetedAudience": ["Beginner developers", "Frontend developers"],
"tags": ["react", "frontend", "javascript", "webdev"],
"duration": {
"hours": 25,
"minutes": 30
},
"certificateTemplate": "template-1",
"certificateOrientation": "landscape",
"includesCertificate": true,
"averageRating": 4.5,
"reviewCount": 128,
"enrolledCount": 1542,
"status": "Published",
"episodes": [
{
"_id": "67a1b2c3d4e5f67890123458",
"title": "Introduction to React",
"summary": "Get started with React basics",
"lessons": [
{
"_id": "67a1b2c3d4e5f67890123459",
"title": "What is React?",
"summary": "Introduction to React library",
"lessonType": "video",
"videoSource": "vimeo",
"videoUrl": "https://vimeo.com/123456790",
"duration": "15 min 30 sec",
"isPreview": true,
"exerciseFiles": [
{
"filename": "react-intro.pdf",
"path": "uploads/pdfs/react-intro.pdf",
"size": 2048000
}
]
}
// ... more lessons
],
"quizzes": [
{
"_id": "67a1b2c3d4e5f67890123460",
"title": "React Basics Quiz",
"summary": "Test your React knowledge",
"questions": [
{
"_id": "67a1b2c3d4e5f67890123461",
"questionText": "What is JSX?",
"questionType": "single-choice",
"points": 10,
"options": [
{
"_id": "67a1b2c3d4e5f67890123462",
"text": "JavaScript XML",
"isCorrect": true
},
// ... more options
]
}
// ... more questions
],
"settings": {
"timeLimit": { "value": 30, "unit": "Minutes" },
"feedbackMode": "reveal",
"passingGrade": 70
}
}
// ... more quizzes
]
}
// ... more episodes
],
"createdAt": "2024-01-10T10:30:00.000Z",
"updatedAt": "2024-01-15T14:20:00.000Z"
},
"hasAccess": false // true if user is enrolled or instructor
}
hasAccess field indicates
whether the authenticated user has access to the course content. This is automatically determined
based on enrollment status or instructor ownership.
Create a new course with basic information. This is the first step in the course creation workflow.
// Headers
Content-Type: multipart/form-data
x-auth-token: YOUR_JWT_TOKEN
// Form Data (example using JavaScript FormData)
const formData = new FormData();
formData.append('title', 'Complete React Developer Course');
formData.append('slug', 'complete-react-developer-course');
formData.append('description', 'Learn React from scratch...');
formData.append('price', '49.99');
formData.append('originalPrice', '99.99');
formData.append('difficultyLevel', 'Intermediate');
formData.append('maxStudents', '0');
formData.append('isPublic', 'false');
formData.append('isQAEnabled', 'true');
formData.append('previewVideoUrl', 'https://vimeo.com/123456789');
formData.append('isMasterclass', 'false');
formData.append('thumbnail', thumbnailFile); // Required file
// JavaScript implementation
const response = await fetch('/api/instructor/courses', {
method: 'POST',
headers: {
'x-auth-token': token
},
body: formData
});
// Response (Success - 201 Created)
{
"success": true,
"message": "Course created successfully!",
"course": {
"_id": "67a1b2c3d4e5f67890123456",
"title": "Complete React Developer Course",
"slug": "complete-react-developer-course",
"description": "Learn React from scratch...",
"instructor": "67a1b2c3d4e5f67890123457",
"price": 49.99,
"originalPrice": 99.99,
"thumbnail": "uploads/thumbnails/1704038400000-react-course.jpg",
"previewVideoUrl": "https://vimeo.com/123456789",
"status": "Draft",
"difficultyLevel": "Intermediate",
"maxStudents": 0,
"isPublic": false,
"isQAEnabled": true,
"isMasterclass": false,
"episodes": [],
"createdAt": "2024-01-15T10:30:00.000Z",
"updatedAt": "2024-01-15T10:30:00.000Z"
}
}
// Response (Error - 400 Bad Request)
{
"success": false,
"message": "Course thumbnail is required."
}
"Draft" status. After this step, the instructor should redirect to the course builder
(edit-course.html?courseId=COURSE_ID) to add curriculum content.
Update course information including metadata, pricing, and settings. This is used in the course builder (Step 2).
// Headers
Content-Type: multipart/form-data
x-auth-token: YOUR_JWT_TOKEN
// Form Data (partial example)
const formData = new FormData();
formData.append('title', 'Updated Course Title');
formData.append('slug', 'updated-course-slug');
formData.append('description', 'Updated description...');
formData.append('price', '59.99');
formData.append('originalPrice', '119.99');
formData.append('status', 'Published');
formData.append('difficultyLevel', 'Advanced');
formData.append('maxStudents', '100');
formData.append('isPublic', 'false');
formData.append('isQAEnabled', 'true');
formData.append('isMasterclass', 'true');
formData.append('school', 'School of Code & Development');
formData.append('categories', JSON.stringify(['Web Development', 'JavaScript', 'React']));
formData.append('language', JSON.stringify(['English']));
formData.append('requirements', 'Basic HTML\\nBasic CSS\\nJavaScript fundamentals');
formData.append('whatYoullLearn', 'React components\\nState management\\nReact hooks');
formData.append('targetedAudience', 'Beginner developers\\nFrontend developers');
formData.append('tags', 'react,frontend,javascript,webdev');
formData.append('durationHours', '30');
formData.append('durationMinutes', '45');
formData.append('certificateTemplate', 'template-1');
formData.append('certificateOrientation', 'landscape');
// Optional file updates
if (newThumbnail) formData.append('thumbnail', newThumbnail);
if (newLogo) formData.append('courseLogo', newLogo);
// JavaScript implementation
const response = await fetch(`/api/courses/${courseId}`, {
method: 'PUT',
headers: { 'x-auth-token': token },
body: formData
});
// Response (Success - 200 OK)
{
"success": true,
"message": "Course updated successfully!",
"course": {
// Updated course object with all fields
}
}
// Request
{
"title": "Introduction to React",
"summary": "Learn the basics of React components"
}
// Response
{
"success": true,
"message": "Topic added successfully!",
"course": {
"episodes": [
{
"_id": "episode_id_123",
"title": "Introduction to React",
"summary": "Learn the basics...",
"lessons": [],
"quizzes": []
}
]
}
}
// Request
{
"title": "Advanced React Concepts",
"summary": "Updated description"
}
// Response
{
"success": true,
"message": "Topic updated successfully!",
"course": { /* updated course object */ }
}
// Response
{
"success": true,
"message": "Topic deleted successfully!",
"course": { /* updated course without the episode */ }
}
// Headers: multipart/form-data
// Form Data:
lessonType: video // or 'reading'
title: Introduction to Components
summary: Learn about React components
videoSource: vimeo // or 'youtube', 'local'
videoUrl: https://vimeo.com/123456789 // if videoSource is vimeo/youtube
duration: 1 hr 15 min 30 sec
isPreview: false
// For reading lessons:
articleBody: <h1>Introduction</h1><p>Content...</p>
// For local video upload:
videoFile: [file] // if videoSource is 'local'
// Exercise files (multiple):
exerciseFiles: [file1, file2]
// Response
{
"success": true,
"message": "Lesson added successfully!",
"course": { /* updated course with new lesson */ }
}
// Similar to POST but includes lesson ID
// Can change lesson type (video ↔ reading)
// To remove exercise files:
DELETE /api/courses/:courseId/episodes/:episodeId/lessons/:lessonId/files
// Request Body: { "filePath": "uploads/pdfs/filename.pdf" }
// Request
{
"title": "JavaScript Fundamentals Quiz",
"summary": "Test your basic JavaScript knowledge",
"settings": {
"timeLimit": { "value": 30, "unit": "Minutes" },
"feedbackMode": "reveal",
"passingGrade": 70,
"maxQuestionsAllowed": 15,
"questionLayout": "single_question",
"questionOrder": "random"
}
}
// Response
{
"success": true,
"message": "Quiz added successfully!",
"course": { /* updated course with new quiz */ }
}
// Single Choice Question Example
{
"questionText": "What is the capital of France?",
"questionType": "single-choice",
"points": 10,
"options": [
{ "text": "London", "isCorrect": false },
{ "text": "Berlin", "isCorrect": false },
{ "text": "Paris", "isCorrect": true },
{ "text": "Madrid", "isCorrect": false }
]
}
// Open-Ended Question Example
{
"questionText": "Explain the concept of closures in JavaScript.",
"questionType": "open-ended",
"points": 25,
"options": [] // Empty array for text-based questions
}
Check if the authenticated student is enrolled in a course.
GET /api/courses/67a1b2c3d4e5f67890123456/enrollment-status
Headers: x-auth-token: STUDENT_JWT_TOKEN
// Response (Enrolled)
{
"success": true,
"isEnrolled": true
}
// Response (Not Enrolled)
{
"success": true,
"isEnrolled": false
}
Mark a lesson as completed for a student and update progress.
POST /api/courses/67a1b2c3d4e5f67890123456/lessons/67a1b2c3d4e5f67890123459/complete
Headers: x-auth-token: STUDENT_JWT_TOKEN
// Response
{
"success": true,
"message": "Lesson marked as complete.",
"progress": 25, // Updated progress percentage
"status": "active" // Course status
}
// From server.js - Progress calculation
const calculateProgress = (enrollment, course) => {
// Count total items (lessons + quizzes)
const totalLessons = course.episodes.reduce(
(sum, episode) => sum + episode.lessons.length, 0
);
const totalQuizzes = course.episodes.reduce(
(sum, episode) => sum + episode.quizzes.length, 0
);
const totalItems = totalLessons + totalQuizzes;
// Count completed items
const completedItems = enrollment.completedLessons.length +
enrollment.completedQuizzes.length;
// Calculate percentage
const progress = totalItems > 0
? Math.round((completedItems / totalItems) * 100)
: 0;
// Update course status
if (progress === 100) {
enrollment.status = 'completed';
} else if (progress > 0) {
enrollment.status = 'active';
}
return {
progress,
status: enrollment.status,
totalItems,
completedItems
};
};
Get instructor dashboard metrics including earnings, student counts, and course statistics.
{
"success": true,
"data": {
"totalCourses": 5,
"totalStudents": 342,
"totalReviews": 128,
"averageRating": 4.5,
"totalEarnings": 12500.50
}
}
Get all courses created by the instructor with enrollment statistics.
{
"success": true,
"courses": [
{
"_id": "67a1b2c3d4e5f67890123456",
"title": "Complete React Developer Course",
"slug": "complete-react-developer-course",
"thumbnail": "uploads/thumbnails/react-course.jpg",
"price": 49.99,
"status": "Published",
"createdAt": "2024-01-10T10:30:00.000Z",
"enrolledCount": 1542, // Added by the API
"averageRating": 4.5,
"reviewCount": 128
}
// ... more courses
]
}
const CourseSchema = new mongoose.Schema({
// Basic Information
title: { type: String, required: true },
slug: { type: String, required: true, unique: true },
description: String,
instructor: { type: mongoose.Schema.Types.ObjectId, ref: 'User' },
// Media
thumbnail: String,
courseLogo: String,
previewVideoUrl: String,
// Pricing
price: Number,
originalPrice: Number,
// Settings
status: {
type: String,
enum: ['Draft', 'Pending', 'Published'],
default: 'Draft'
},
difficultyLevel: String,
maxStudents: { type: Number, default: 0 }, // 0 = unlimited
isPublic: Boolean,
isQAEnabled: Boolean,
isMasterclass: Boolean,
// Metadata
school: String,
categories: [String],
language: [String],
requirements: [String],
whatYoullLearn: [String],
targetedAudience: [String],
tags: [String],
// Duration
duration: {
hours: Number,
minutes: Number
},
// Certificate
certificateTemplate: String,
certificateOrientation: String,
includesCertificate: Boolean,
// Ratings
averageRating: Number,
reviewCount: Number,
// Curriculum
episodes: [{
title: String,
summary: String,
lessons: [{
title: String,
summary: String,
lessonType: { type: String, enum: ['video', 'reading'] },
videoSource: String,
videoUrl: String,
videoPath: String,
duration: String,
articleBody: String,
isPreview: Boolean,
exerciseFiles: [{
filename: String,
path: String,
size: Number
}]
}],
quizzes: [{
title: String,
summary: String,
questions: [{
questionText: String,
questionType: String,
points: Number,
options: [{
text: String,
isCorrect: Boolean
}]
}],
settings: {
timeLimit: { value: Number, unit: String },
feedbackMode: String,
passingGrade: Number,
maxQuestionsAllowed: Number,
questionLayout: String,
questionOrder: String
}
}]
}],
// Timestamps
createdAt: { type: Date, default: Date.now },
updatedAt: { type: Date, default: Date.now }
});
| Error Code | HTTP Status | Description | Solution |
|---|---|---|---|
COURSE_001 |
404 | Course not found | Check course ID, ensure course exists |
COURSE_002 |
403 | Not course owner | Only course instructor can modify |
COURSE_003 |
400 | Duplicate slug | Choose unique course slug |
COURSE_004 |
400 | Missing required fields | Provide all required course data |
EPISODE_001 |
404 | Episode not found | Check episode ID |
LESSON_001 |
404 | Lesson not found | Check lesson ID |
QUIZ_001 |
404 | Quiz not found | Check quiz ID |
.select() to fetch only needed fields
// 1. Create course (Step 1)
async function createCourse(courseData, thumbnailFile) {
const formData = new FormData();
Object.keys(courseData).forEach(key => {
formData.append(key, courseData[key]);
});
formData.append('thumbnail', thumbnailFile);
const response = await fetch('/api/instructor/courses', {
method: 'POST',
headers: { 'x-auth-token': token },
body: formData
});
return await response.json();
}
// 2. Update course details (Step 2)
async function updateCourse(courseId, updatedData, files = {}) {
const formData = new FormData();
Object.keys(updatedData).forEach(key => {
if (typeof updatedData[key] === 'object') {
formData.append(key, JSON.stringify(updatedData[key]));
} else {
formData.append(key, updatedData[key]);
}
});
if (files.thumbnail) formData.append('thumbnail', files.thumbnail);
if (files.logo) formData.append('courseLogo', files.logo);
const response = await fetch(`/api/courses/${courseId}`, {
method: 'PUT',
headers: { 'x-auth-token': token },
body: formData
});
return await response.json();
}
// 3. Add curriculum content
async function addCurriculum(courseId, episodes) {
for (const episode of episodes) {
// Add episode
const episodeRes = await fetch(`/api/courses/${courseId}/episodes`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-auth-token': token
},
body: JSON.stringify({
title: episode.title,
summary: episode.summary
})
});
const episodeData = await episodeRes.json();
const episodeId = episodeData.course.episodes[0]._id;
// Add lessons to episode
for (const lesson of episode.lessons) {
await fetch(`/api/courses/${courseId}/episodes/${episodeId}/lessons`, {
method: 'POST',
headers: { 'x-auth-token': token },
body: lesson.formData // FormData with files
});
}
// Add quizzes to episode
for (const quiz of episode.quizzes) {
await fetch(`/api/courses/${courseId}/episodes/${episodeId}/quizzes`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-auth-token': token
},
body: JSON.stringify(quiz)
});
}
}
}
// 4. Publish course
async function publishCourse(courseId) {
const response = await fetch(`/api/courses/${courseId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'x-auth-token': token
},
body: JSON.stringify({ status: 'Published' })
});
return await response.json();
}
Comprehensive user management system handling authentication, profiles, roles, wishlists, enrollments, and password recovery for both students and instructors.
firstName, lastName - Requiredemail - Unique, lowercase, requiredpassword - Bcrypt hashed, requiredrole - Student or Instructoravatar, coverPhoto - Profile mediaenrolledCourses - Course progress trackingwishlist - Saved courses for latersocial - Social media linkspurchasedAssessments - Bought assessmentsresetPasswordToken - Password recovery| Method | Endpoint | Description | Role Required |
|---|---|---|---|
| POST | /api/register |
Register as student | None |
| POST | /api/register-instructor |
Register as instructor | None |
| POST | /api/login |
Login and get JWT | None |
| GET | /api/user/profile |
Get current user profile | Any |
| PUT | /api/user/profile |
Update profile information | Any |
| PUT | /api/user/password |
Change password | Any |
| PUT | /api/user/social |
Update social links | Any |
| POST | /api/user/avatar |
Upload avatar image | Any |
| POST | /api/user/cover |
Upload cover photo | Any |
| POST | /api/user/wishlist/toggle |
Add/remove course from wishlist | Any |
| GET | /api/student/wishlist |
Get user's wishlist | Student |
| POST | /api/forgot-password |
Request password reset email | None |
| POST | /api/reset-password |
Reset password with token | None |
// Request
{
"firstName": "John",
"lastName": "Doe",
"email": "john@example.com",
"password": "SecurePass123!"
}
// Response (Success - 201 Created)
{
"success": true,
"message": "User registered successfully!",
"user": {
"id": "507f1f77bcf86cd799439011",
"firstName": "John",
"lastName": "Doe",
"email": "john@example.com",
"role": "student",
"createdAt": "2024-01-15T10:30:00.000Z"
}
}
// Response (Error - 400 Bad Request)
{
"success": false,
"message": "User already exists"
}
// Same request structure as student registration
// Response includes role: 'instructor'
// Response (Success - 201 Created)
{
"success": true,
"message": "Instructor registered successfully!",
"user": {
"id": "507f1f77bcf86cd799439012",
"firstName": "Jane",
"lastName": "Smith",
"email": "jane@example.com",
"role": "instructor", // ← Note this difference
"createdAt": "2024-01-15T10:30:00.000Z"
}
}
// Request
{
"username": "john@example.com", // Note: field is 'username' not 'email'
"password": "SecurePass123!"
}
// Response (Success - 200 OK)
{
"success": true,
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"user": {
"id": "507f1f77bcf86cd799439011",
"role": "student",
"name": "John Doe",
"avatar": "uploads/avatars/default.jpg"
}
}
// JWT Payload Structure
{
"user": {
"id": "507f1f77bcf86cd799439011",
"role": "student",
"name": "John Doe",
"avatar": "uploads/avatars/default.jpg"
},
"iat": 1704038400,
"exp": 1704056400 // 5-hour expiration
}
// Response (Error - 400 Bad Request)
{
"success": false,
"message": "Invalid Credentials"
}
// Headers
x-auth-token: YOUR_JWT_TOKEN
// Response (Success - 200 OK)
{
"success": true,
"data": {
"registrationDate": "Mon Jan 15 2024",
"firstName": "John",
"lastName": "Doe",
"username": "john@example.com",
"email": "john@example.com",
"phone": "+1234567890",
"occupation": "Web Developer",
"bio": "Passionate about learning and teaching web technologies.",
"avatar": "uploads/avatars/1704038400000-profile.jpg",
"coverPhoto": "uploads/covers/1704038400000-cover.jpg",
"social": {
"facebook": "https://facebook.com/johndoe",
"twitter": "https://twitter.com/johndoe",
"linkedin": "https://linkedin.com/in/johndoe",
"website": "https://johndoe.dev",
"github": "https://github.com/johndoe"
}
}
}
// Request
{
"firstName": "John",
"lastName": "Doe",
"phone": "+1234567890",
"occupation": "Senior Web Developer",
"bio": "Updated bio with more details...",
"email": "john.new@example.com" // Email can be updated
}
// Response (Success - 200 OK)
{
"success": true,
"message": "Profile updated successfully"
}
// Response (Error - 400 Bad Request)
{
"success": false,
"message": "Email is already in use."
}
// Request
{
"currentPassword": "OldPass123!",
"newPassword": "NewSecurePass456!"
}
// Response (Success - 200 OK)
{
"success": true,
"message": "Password updated successfully!"
}
// Response (Error - 400 Bad Request)
{
"success": false,
"message": "Incorrect current password."
}
// Request
{
"facebook": "https://facebook.com/johndoe",
"twitter": "https://twitter.com/johndoe",
"linkedin": "https://linkedin.com/in/johndoe",
"website": "https://johndoe.dev",
"github": "https://github.com/johndoe"
}
// Response (Success - 200 OK)
{
"success": true,
"message": "Social links updated successfully!"
}
// Headers
Content-Type: multipart/form-data
x-auth-token: YOUR_JWT_TOKEN
// Form Data
avatar: [image file] // Max 2MB, JPG/PNG
// Response (Success - 200 OK)
{
"success": true,
"msg": "Avatar updated successfully",
"filePath": "uploads/avatars/1704038400000-profile.jpg"
}
// Frontend Implementation
async function uploadAvatar(file) {
const formData = new FormData();
formData.append('avatar', file);
const response = await fetch('/api/user/avatar', {
method: 'POST',
headers: {
'x-auth-token': auth.getToken()
},
body: formData
});
return await response.json();
}
// Similar to avatar upload but different field
// Form Data: coverPhoto: [image file]
// Response
{
"success": true,
"msg": "Cover photo updated successfully",
"filePath": "uploads/covers/1704038400000-cover.jpg"
}
// Request
{
"courseId": "67a1b2c3d4e5f67890123456"
}
// Response (Added to wishlist)
{
"success": true,
"wishlist": [
"67a1b2c3d4e5f67890123456",
"67a1b2c3d4e5f67890123457"
]
}
// Response (Removed from wishlist)
{
"success": true,
"wishlist": [
"67a1b2c3d4e5f67890123457"
]
}
// Frontend Implementation
async function toggleWishlist(courseId) {
const response = await fetch('/api/user/wishlist/toggle', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-auth-token': auth.getToken()
},
body: JSON.stringify({ courseId })
});
return await response.json();
}
// Response (Success - 200 OK)
{
"success": true,
"courses": [
{
"_id": "67a1b2c3d4e5f67890123456",
"title": "Complete React Developer Course",
"thumbnail": "uploads/thumbnails/react-course.jpg",
"price": 49.99,
"originalPrice": 99.99,
"instructor": {
"_id": "67a1b2c3d4e5f67890123457",
"firstName": "John",
"lastName": "Doe"
},
"averageRating": 4.5,
"reviewCount": 128
}
// ... more wishlisted courses
]
}
// Request
{
"email": "john@example.com"
}
// Response (Always returns 200 for security)
{
"success": true,
"message": "If a matching account was found, a password reset email has been sent."
}
// Process:
// 1. Generates cryptographically secure token
// 2. Sets token expiration (1 hour)
// 3. Sends email with reset link
// 4. Link format: /reset-password.html?token=token_here
// Request
{
"token": "reset_token_from_email",
"newPassword": "NewSecurePass456!",
"confirmNewPassword": "NewSecurePass456!"
}
// Response (Success - 200 OK)
{
"success": true,
"message": "Password has been successfully reset. You can now log in."
}
// Response (Error - 400 Bad Request)
{
"success": false,
"message": "Password reset link is invalid or has expired. Please try again."
}
// Security Features:
// 1. Token expiration check
// 2. Password match validation
// 3. Password strength requirements
// 4. Token single-use (cleared after reset)
const userSchema = new mongoose.Schema({
// Basic Information
firstName: { type: String, required: true },
lastName: { type: String, required: true },
email: {
type: String,
required: true,
unique: true,
lowercase: true
},
password: { type: String, required: true },
role: {
type: String,
enum: ['student', 'instructor'],
default: 'student'
},
// Profile Media
avatar: { type: String },
coverPhoto: { type: String },
// Profile Details
phone: String,
occupation: String,
bio: String,
// Social Media Links
social: {
facebook: String,
twitter: String,
linkedin: String,
website: String,
github: String,
},
// Registration
registrationDate: { type: Date, default: Date.now },
// Course Management
wishlist: [{
type: mongoose.Schema.Types.ObjectId,
ref: 'Course'
}],
// Enrollment Tracking
enrolledCourses: [{
course: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Course'
},
progress: {
type: Number,
default: 0
},
status: {
type: String,
default: 'active',
enum: ['active', 'completed']
},
completedLessons: [{
type: mongoose.Schema.Types.ObjectId
}],
completedQuizzes: [{
type: mongoose.Schema.Types.ObjectId
}]
}],
// Password Recovery
resetPasswordToken: String,
resetPasswordExpires: Date,
// Assessments
purchasedAssessments: {
type: [{
type: mongoose.Schema.Types.ObjectId,
ref: 'Assessment'
}],
default: []
}
}, {
timestamps: true // Adds createdAt and updatedAt automatically
});
// Password Hashing Middleware
userSchema.pre('save', async function(next) {
if (!this.isModified('password')) {
return next();
}
try {
const salt = await bcrypt.genSalt(10);
this.password = await bcrypt.hash(this.password, salt);
next();
} catch (error) {
next(error);
}
});
// Password Comparison Method
userSchema.methods.comparePassword = function(candidatePassword) {
return bcrypt.compare(candidatePassword, this.password);
};
const jwt = require('jsonwebtoken');
const User = require('./models/User');
module.exports = async function(req, res, next) {
// Get token from header
const token = req.header('x-auth-token');
// Check if no token
if (!token) {
return res.status(401).json({
success: false,
message: 'No token, authorization denied'
});
}
try {
// Verify token
const decoded = jwt.verify(token, process.env.JWT_SECRET);
// Optional: Check if user still exists and is active
const user = await User.findById(decoded.user.id).select('-password');
if (!user) {
return res.status(401).json({
success: false,
message: 'User no longer exists'
});
}
// Add user to request object
req.user = decoded.user;
next();
} catch (err) {
console.error('Auth middleware error:', err.message);
// Handle specific JWT errors
if (err.name === 'TokenExpiredError') {
return res.status(401).json({
success: false,
message: 'Token has expired. Please log in again.'
});
}
if (err.name === 'JsonWebTokenError') {
return res.status(401).json({
success: false,
message: 'Invalid token. Please log in again.'
});
}
// Generic error
res.status(500).json({
success: false,
message: 'Server error during authentication'
});
}
};
// Role-based middleware examples
const isInstructor = (req, res, next) => {
if (req.user && req.user.role === 'instructor') {
return next();
}
return res.status(403).json({
success: false,
message: 'Access denied. Instructor role required.'
});
};
const isStudent = (req, res, next) => {
if (req.user && req.user.role === 'student') {
return next();
}
return res.status(403).json({
success: false,
message: 'Access denied. Student role required.'
});
};
// Course ownership middleware
const isCourseOwner = async (req, res, next) => {
try {
const course = await Course.findById(req.params.courseId);
if (!course) {
return res.status(404).json({
success: false,
message: 'Course not found'
});
}
if (course.instructor.toString() !== req.user.id) {
return res.status(403).json({
success: false,
message: 'User not authorized to access this course'
});
}
next();
} catch (error) {
res.status(500).json({
success: false,
message: 'Server error'
});
}
};
module.exports = { auth, isInstructor, isStudent, isCourseOwner };
// In server.js - Login endpoint
app.post('/api/login', async (req, res) => {
try {
const { username, password } = req.body;
const user = await User.findOne({ email: username });
if (!user) {
return res.status(400).json({
success: false,
message: 'Invalid Credentials'
});
}
const isMatch = await user.comparePassword(password);
if (!isMatch) {
return res.status(400).json({
success: false,
message: 'Invalid Credentials'
});
}
// Create JWT payload
const payload = {
user: {
id: user.id,
role: user.role,
name: `${user.firstName} ${user.lastName}`,
avatar: user.avatar
}
};
// Sign token with 5-hour expiration
jwt.sign(
payload,
process.env.JWT_SECRET,
{ expiresIn: '5h' },
(err, token) => {
if (err) throw err;
res.status(200).json({
success: true,
token,
user: payload.user
});
}
);
} catch (error) {
console.error('Login error:', error);
res.status(500).json({
success: false,
message: 'Server error'
});
}
});
// Required environment variable
// .env file:
JWT_SECRET=your_super_secret_key_change_in_production
// In emailService.js
const nodemailer = require('nodemailer');
const crypto = require('crypto');
// Configure email transporter
const transporter = nodemailer.createTransport({
host: process.env.EMAIL_HOST,
port: process.env.EMAIL_PORT,
secure: process.env.EMAIL_PORT === '465',
auth: {
user: process.env.EMAIL_USER,
pass: process.env.EMAIL_PASS
}
});
// Send password reset email
async function sendPasswordResetEmail(email, userName, resetToken) {
const resetUrl = `${process.env.APP_URL}/reset-password.html?token=${resetToken}`;
const mailOptions = {
from: `"TruLern LMS" <${process.env.EMAIL_USER}>`,
to: email,
subject: 'Password Reset Request - TruLern LMS',
html: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #333;">Password Reset Request</h2>
<p>Hello ${userName},</p>
<p>Click the button below to reset your password:</p>
<div style="text-align: center; margin: 30px 0;">
<a href="${resetUrl}"
style="background-color: #4CAF50; color: white; padding: 12px 24px;
text-decoration: none; border-radius: 5px; font-weight: bold;">
Reset Password
</a>
</div>
</div>
`
};
try {
await transporter.sendMail(mailOptions);
console.log(`Password reset email sent to ${email}`);
} catch (error) {
console.error('Error sending password reset email:', error);
throw error;
}
}
module.exports = { sendPasswordResetEmail };
| Error Code | HTTP Status | Description | Solution |
|---|---|---|---|
AUTH_001 |
401 | No authentication token | Login required |
AUTH_002 |
401 | Invalid or expired token | Re-login required |
AUTH_003 |
403 | Insufficient permissions | Check user role |
USER_001 |
400 | User already exists | Use different email |
USER_002 |
400 | Invalid credentials | Check email/password |
USER_003 |
400 | Email already in use | Use different email |
PASSWORD_001 |
400 | Incorrect current password | Verify current password |
PASSWORD_002 |
400 | Password reset token expired | Request new reset link |
FILE_001 |
400 | Invalid file type/size | Check upload requirements |
// Example: Complete user registration and setup
async function completeUserRegistration(userData) {
try {
// 1. Register user
const registerResponse = await fetch('/api/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(userData)
});
const registerResult = await registerResponse.json();
if (!registerResult.success) {
throw new Error(registerResult.message);
}
// 2. Login to get token
const loginResponse = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: userData.email,
password: userData.password
})
});
const loginResult = await loginResponse.json();
if (!loginResult.success) {
throw new Error(loginResult.message);
}
// 3. Update profile with additional info
const updateResponse = await fetch('/api/user/profile', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'x-auth-token': loginResult.token
},
body: JSON.stringify({
phone: '+1234567890',
occupation: 'Web Developer',
bio: 'Passionate about learning...'
})
});
const updateResult = await updateResponse.json();
if (!updateResult.success) {
throw new Error(updateResult.message);
}
// 4. Upload profile picture (optional)
if (profilePicture) {
const formData = new FormData();
formData.append('avatar', profilePicture);
const avatarResponse = await fetch('/api/user/avatar', {
method: 'POST',
headers: { 'x-auth-token': loginResult.token },
body: formData
});
const avatarResult = await avatarResponse.json();
}
return {
success: true,
message: 'Registration complete',
token: loginResult.token,
user: loginResult.user
};
} catch (error) {
console.error('Registration error:', error);
return {
success: false,
message: error.message
};
}
}
// Example: Password reset flow
async function handlePasswordReset(email) {
try {
// 1. Request reset email
const resetRequest = await auth.requestPasswordReset(email);
if (!resetRequest.success) {
return resetRequest;
}
// 2. User receives email and clicks link
// 3. On reset page, collect new password
const resetResult = await auth.resetPassword(
resetTokenFromURL,
newPassword,
confirmPassword
);
if (resetResult.success) {
// 4. Redirect to login with success message
window.location.href = '/login.html?message=Password reset successful';
}
return resetResult;
} catch (error) {
console.error('Password reset error:', error);
return { success: false, message: error.message };
}
}
Symptoms: Getting 401 errors, being logged out unexpectedly.
Debug steps:
// 1. Check if token exists
console.log('Token exists:', localStorage.getItem('lmsToken') !== null);
// 2. Check token age
const tokenAge = Date.now() - localStorage.getItem('tokenTimestamp');
console.log('Token age (hours):', tokenAge / (60 * 60 * 1000));
// 3. Check JWT payload (debug only)
const token = localStorage.getItem('lmsToken');
const payload = JSON.parse(atob(token.split('.')[1]));
console.log('Token payload:', {
userId: payload.user.id,
role: payload.user.role,
expires: new Date(payload.exp * 1000)
});
// 4. Manual token refresh (if implemented)
async function refreshToken() {
try {
const response = await fetch('/api/refresh-token', {
method: 'POST',
headers: { 'x-auth-token': auth.getToken() }
});
if (response.ok) {
const data = await response.json();
auth.setAuth(data.token, data.user);
}
} catch (error) {
auth.logout();
}
}
Common issues:
| Issue | Solution |
|---|---|
| Email not received | Check spam folder, verify SMTP configuration |
| Reset link expired | Links expire in 1 hour, request new one |
| Invalid token error | Token may have been used already |
| Password requirements not met | Minimum 8 characters with complexity |
Checklist:
// Debug file upload
const file = document.getElementById('avatar-input').files[0];
console.log('File details:', {
name: file.name,
size: file.size,
type: file.type,
lastModified: file.lastModified
});
// Test upload with curl
curl -X POST http://localhost:5000/api/user/avatar \
-H "x-auth-token: YOUR_TOKEN" \
-F "avatar=@profile.jpg"
TruLern LMS features a comprehensive multi-gateway payment system with support for Stripe, PayPal, and Razorpay. All gateways follow a consistent verification and enrollment flow with automatic order creation.
Stripe, PayPal, and Razorpay with consistent API patterns
Server-side verification with signature validation
Automatic course/assessment enrollment after successful payment
User selects payment method, frontend calls gateway-specific create endpoint
Payment processed by selected gateway (Stripe, PayPal, or Razorpay)
Backend verifies payment authenticity and processes enrollment
User enrolled in purchased items and order records created
| Gateway | Method | Endpoint | Description | Auth Required |
|---|---|---|---|---|
| Stripe | POST | /api/payments/stripe/create-intent |
Create Stripe PaymentIntent | Yes |
| POST | /api/payments/stripe/verify |
Verify Stripe payment and enroll user | Yes | |
| PayPal | POST | /api/payments/paypal/create-order |
Create PayPal order | Yes |
| POST | /api/payments/paypal/capture-order |
Capture PayPal payment and enroll user | Yes | |
| Razorpay | POST | /api/payments/create-order |
Create Razorpay order | Yes |
| POST | /api/payments/verify |
Verify Razorpay payment and enroll user | Yes |
// ========================================== // 🛠️ SHARED HELPER: ENROLL USER // ========================================== // This function handles the database logic for ALL gateways. const enrollUser = async (userId, items) => { console.log(`[ENROLLMENT] Starting enrollment for user: ${userId}`); const user = await User.findById(userId); if (!user) throw new Error('User not found'); let changesMade = false; // 1. Process Courses const courseIds = items.filter(item => item.type === 'course').map(item => item.id); if (courseIds.length > 0) { const newEnrollments = courseIds .filter(courseId => !user.enrolledCourses.some(e => e.course && e.course.toString() === courseId)) .map(courseId => ({ course: courseId })); if (newEnrollments.length > 0) { user.enrolledCourses.push(...newEnrollments); changesMade = true; console.log(`[ENROLLMENT] Enrolled in ${newEnrollments.length} new courses.`); } } // 2. Process Assessments const assessmentIds = items.filter(item => item.type === 'assessment').map(item => item.id); if (assessmentIds.length > 0) { const newAssessments = assessmentIds .filter(id => !user.purchasedAssessments.some(ownedId => ownedId.toString() === id)) .map(id => new mongoose.Types.ObjectId(id)); if (newAssessments.length > 0) { user.purchasedAssessments.push(...newAssessments); changesMade = true; console.log(`[ENROLLMENT] Added ${newAssessments.length} new assessments.`); } } // 3. Save to DB if (changesMade) { await user.save(); console.log("[ENROLLMENT] User data saved successfully."); return true; } else { console.log("[ENROLLMENT] No new items to enroll."); return false; } };
Create a Stripe PaymentIntent with support for multiple payment methods.
// Request { "amount": 4999, // Amount in smallest currency unit (cents for USD) "currency": "usd" // Optional, defaults to 'usd' } // Response (Success - 200 OK) { "success": true, "clientSecret": "pi_3Mx123456789_secret_abc123456789" } // Frontend Implementation (checkout.js) async function createStripePaymentIntent(amount) { const response = await fetch('/api/payments/stripe/create-intent', { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-auth-token': auth.getToken() }, body: JSON.stringify({ amount: Math.round(amount * 100), // Convert dollars to cents currency: 'usd' }) }); return await response.json(); } // Stripe.js Integration const stripe = Stripe(STRIPE_PUBLISHABLE_KEY); const elements = stripe.elements(); const paymentElement = elements.create('payment'); paymentElement.mount('#payment-element'); // Confirm Payment const { error } = await stripe.confirmPayment({ elements, confirmParams: { return_url: `${window.location.origin}/success.html`, }, });
Verify Stripe payment, enroll user, and create order records.
// Request { "paymentIntentId": "pi_3Mx123456789", "items": [ { "id": "67a1b2c3d4e5f67890123456", "type": "course", "price": 49.99, "title": "Complete React Developer Course" } ], "totalAmount": 49.99 } // Response (Success - 200 OK) { "success": true, "message": "Payment verified, user enrolled, and orders created." } // Response (Error - 400 Bad Request) { "success": false, "message": "Payment not successful." } // Server-side Processing Logic 1. Retrieve PaymentIntent from Stripe 2. Check if status === 'succeeded' 3. Call enrollUser() to process enrollment 4. Create Order documents for each course 5. Return success response
Order documents for each purchased course, enabling detailed order history and
reporting.
Create a PayPal order using PayPal Orders API v2.
// Request { "amount": "49.99" // String value required by PayPal } // Response (Success - 200 OK) { "success": true, "id": "5O190127TN364715T" } // PayPal Configuration (server-side) const PAYPAL_API = process.env.PAYPAL_MODE === 'sandbox' ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com'; // PayPal Access Token Helper const getPayPalAccessToken = async () => { const authString = Buffer.from(process.env.PAYPAL_CLIENT_ID + ':' + process.env.PAYPAL_CLIENT_SECRET).toString('base64'); const response = await axios.post(`${PAYPAL_API}/v1/oauth2/token`, 'grant_type=client_credentials', { headers: { 'Authorization': `Basic ${authString}`, 'Content-Type': 'application/x-www-form-urlencoded' } }); return response.data.access_token; }; // Frontend Implementation async function createPayPalOrder(amount) { const response = await fetch('/api/payments/paypal/create-order', { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-auth-token': auth.getToken() }, body: JSON.stringify({ amount }) }); return await response.json(); }
Capture PayPal payment and process enrollment.
// Request { "orderID": "5O190127TN364715T", "items": [ { "id": "67a1b2c3d4e5f67890123456", "type": "course", "price": 49.99, "title": "Complete React Developer Course" } ], "totalAmount": 49.99 } // Response (Success - 200 OK) { "success": true, "status": "COMPLETED", "data": { /* PayPal order details */ } } // PayPal Button Integration (Frontend) paypal.Buttons({ createOrder: async () => { const response = await fetch('/api/payments/paypal/create-order', { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-auth-token': auth.getToken() }, body: JSON.stringify({ amount: cartTotal.toString() }) }); const orderData = await response.json(); return orderData.id; // Return PayPal order ID }, onApprove: async (data) => { // Capture the order const response = await fetch('/api/payments/paypal/capture-order', { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-auth-token': auth.getToken() }, body: JSON.stringify({ orderID: data.orderID, items: cartItems, totalAmount: cartTotal }) }); const result = await response.json(); if (result.success) { window.location.href = '/success.html'; } } }).render('#paypal-button-container');
Create a Razorpay order for Indian payment methods (UPI, Netbanking, Wallets).
// Request { "items": [ { "id": "67a1b2c3d4e5f67890123456", "type": "course", "price": 2999, "title": "Complete React Developer Course" } ], "totalAmount": 2999 // Amount in INR } // Response (Success - 200 OK) { "success": true, "order": { "id": "order_JfFw8k8k8k8k8", "entity": "order", "amount": 299900, // Amount in paise "amount_paid": 0, "amount_due": 299900, "currency": "INR", "receipt": "receipt_1704038400000", "status": "created", "attempts": 0, "created_at": 1704038400 }, "calculatedAmount": 2999 } // Razorpay Configuration const razorpay = new Razorpay({ key_id: process.env.RAZORPAY_KEY_ID, key_secret: process.env.RAZORPAY_KEY_SECRET, }); // Amount Calculation Logic const options = { amount: Math.round(totalAmount * 100), // Convert to paise currency: "INR", receipt: `receipt_${Date.now()}`, };
Verify Razorpay payment with signature validation and process enrollment.
// Request { "razorpay_order_id": "order_JfFw8k8k8k8k8", "razorpay_payment_id": "pay_JfFw9l9l9l9l9", "razorpay_signature": "abc123def456ghi789", "items": [ { "id": "67a1b2c3d4e5f67890123456", "type": "course", "price": 2999, "title": "Complete React Developer Course" } ], "totalAmount": 2999 } // Response (Success - 200 OK) { "success": true, "message": "Payment verified successfully and orders created!" } // Response (Error - 400 Bad Request) { "success": false, "message": "Transaction not legit!" } // Signature Verification Logic const shasum = crypto.createHmac('sha256', process.env.RAZORPAY_KEY_SECRET); shasum.update(`${razorpay_order_id}|${razorpay_payment_id}`); const digest = shasum.digest('hex'); if (digest !== razorpay_signature) { return res.status(400).json({ success: false, message: 'Transaction not legit!' }); } // Frontend Razorpay Integration const options = { key: RAZORPAY_KEY, amount: order.amount, currency: order.currency, name: "TruLern LMS", description: "Course Purchase", order_id: order.id, handler: async function(response) { // Verify payment on backend const verifyResponse = await fetch('/api/payments/verify', { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-auth-token': auth.getToken() }, body: JSON.stringify({ razorpay_order_id: response.razorpay_order_id, razorpay_payment_id: response.razorpay_payment_id, razorpay_signature: response.razorpay_signature, items: cartItems, totalAmount: cartTotal }) }); const result = await verifyResponse.json(); if (result.success) { window.location.href = '/success.html'; } }, prefill: { name: user.name, email: user.email, contact: user.phone || '' }, theme: { color: "#6C5CE7" } }; const rzp = new Razorpay(options); rzp.open();
const mongoose = require('mongoose'); const OrderSchema = new mongoose.Schema({ user: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true }, course: { type: mongoose.Schema.Types.ObjectId, ref: 'Course', required: true }, amount: { type: Number, required: true }, status: { type: String, enum: ['pending', 'success', 'failed', 'refunded', 'processing', 'canceled', 'on hold'], default: 'pending' }, paymentId: { type: String // Stores Razorpay/Stripe Payment ID }, orderId: { type: String // Stores Razorpay Order ID }, createdAt: { type: Date, default: Date.now } }); module.exports = mongoose.model('Order', OrderSchema);
# ================================ # PAYMENT GATEWAY CONFIGURATION # ================================ # --- STRIPE --- STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx STRIPE_PUBLISHABLE_KEY=pk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # --- PAYPAL --- PAYPAL_CLIENT_ID=AYxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx PAYPAL_CLIENT_SECRET=ECxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx PAYPAL_MODE=sandbox # or 'live' CURRENCY_CODE=USD # Default currency # --- RAZORPAY (India) --- RAZORPAY_KEY_ID=rzp_test_xxxxxxxxxxxxxxxx RAZORPAY_KEY_SECRET=yyyyyyyyyyyyyyyyyyyyyyyyyyyy # ================================ # PAYMENT SETTINGS # ================================ DEFAULT_CURRENCY=USD ENABLE_TEST_MODE=true # Set to false in production
import React, { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { useUI } from '../../context/UIContext';
const CartSideMenu = () => {
const { isCartOpen, closeAll } = useUI();
const [cartItems, setCartItems] = useState([]);
const [subtotal, setSubtotal] = useState(0);
// Load cart from localStorage
const loadCart = () => {
try {
const storedCart = localStorage.getItem('cart_items');
const cartData = storedCart ? JSON.parse(storedCart) : [];
setCartItems(cartData);
const total = cartData.reduce((sum, item) =>
sum + (item.price * (item.quantity || 1)), 0);
setSubtotal(total);
} catch (error) {
console.error('Error loading cart:', error);
}
};
useEffect(() => {
loadCart();
window.addEventListener('cartUpdated', loadCart);
return () => window.removeEventListener('cartUpdated', loadCart);
}, []);
// Helper to update storage and notify
const syncAndNotify = (updatedItems) => {
localStorage.setItem('cart_items', JSON.stringify(updatedItems));
window.dispatchEvent(new Event('cartUpdated'));
};
// Actions
const removeItem = (id) => {
const updatedItems = cartItems.filter(item => String(item.id) !== String(id));
syncAndNotify(updatedItems);
};
const updateQuantity = (id, newQuantity) => {
if (newQuantity <= 0) {
removeItem(id);
return;
}
const updatedItems = cartItems.map(item =>
String(item.id) === String(id) ? { ...item, quantity: newQuantity } : item
);
syncAndNotify(updatedItems);
};
const clearCart = () => {
if (window.confirm('Are you sure you want to clear your cart?')) {
syncAndNotify([]);
}
};
return (
<div className={`tru-cart-side-menu ${isCartOpen ? 'active' : ''}`}>
<div className="inner-wrapper">
<div className="inner-top">
<div className="content">
<div className="title">
<h4 className="title mb--0">Your Cart</h4>
</div>
<div className="tru-btn-close">
<button className="minicart-close-button tru-round-btn" onClick={closeAll}>
<i className="feather-x"></i>
</button>
</div>
</div>
</div>
<nav className="side-nav w-100">
{cartItems.length === 0 ? (
<div className="empty-cart-message">
<div className="empty-icon">
<i className="feather-shopping-cart"></i>
</div>
<h5>Your cart is empty</h5>
<p>Add some courses to get started!</p>
<Link to="/explore-courses" className="tru-btn btn-border-gradient radius-round btn-sm" onClick={closeAll}>
Browse Courses
</Link>
</div>
) : (
<ul className="tru-minicart-wrapper">
{cartItems.map(item => (
<li key={item.id} className="minicart-item">
<div className="minicart-thumb">
<img src={item.image || item.thumbnail} alt={item.name || item.title} />
</div>
<div className="minicart-item-content">
<h6 className="title">
<Link to={`/course-details/${item.id}`} onClick={closeAll}>
{item.name || item.title}
</Link>
</h6>
<div className="quantity-price">
<div className="quantity-control">
<button
className="qty-btn dec"
onClick={() => updateQuantity(item.id, (item.quantity || 1) - 1)}
>
<i className="feather-minus"></i>
</button>
<span className="quantity">{item.quantity || 1}</span>
<button
className="qty-btn inc"
onClick={() => updateQuantity(item.id, (item.quantity || 1) + 1)}
>
<i className="feather-plus"></i>
</button>
</div>
<span className="price">
${(item.price * (item.quantity || 1)).toFixed(2)}
</span>
</div>
</div>
<button
className="remove-item"
onClick={() => removeItem(item.id)}
aria-label="Remove item"
>
<i className="feather-x"></i>
</button>
</li>
))}
</ul>
)}
</nav>
{cartItems.length > 0 && (
<div className="tru-minicart-footer">
<hr className="mb--0" />
<div className="tru-cart-subttotal">
<p className="subtotal"><strong>Subtotal:</strong></p>
<p className="price">${subtotal.toFixed(2)}</p>
</div>
<div className="tru-cart-actions mt--15">
<button
className="tru-btn btn-border btn-sm w-100 mb--10"
onClick={clearCart}
>
Clear Cart
</button>
</div>
<hr className="mb--0" />
<div className="tru-minicart-bottom mt--20">
<div className="view-cart-btn">
<Link
className="tru-btn btn-border-gradient hover-icon-reverse radius-round w-100 text-center"
to="/cart"
onClick={closeAll}
>
<span className="btn-text">View Cart</span>
<span className="btn-icon"><i className="feather-arrow-right"></i></span>
<span className="btn-icon"><i className="feather-arrow-right"></i></span>
</Link>
</div>
<div className="checkout-btn mt--20">
<Link
className="tru-btn btn-border-gradient hover-icon-reverse radius-round w-100 text-center"
to="/checkout"
onClick={closeAll}
>
<span className="btn-text">Proceed to Checkout</span>
<span className="btn-icon"><i className="feather-arrow-right"></i></span>
<span className="btn-icon"><i className="feather-arrow-right"></i></span>
</Link>
</div>
</div>
</div>
)}
</div>
</div>
);
};
export default CartSideMenu;
import React, { useState, useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom';
const CartPage = () => {
const navigate = useNavigate();
const [cartItems, setCartItems] = useState([]);
const [subTotal, setSubTotal] = useState(0);
const [isLoading, setIsLoading] = useState(true);
// Load cart from localStorage
const loadCartData = () => {
setIsLoading(true);
try {
const storedCart = localStorage.getItem('cart_items');
const cart = storedCart ? JSON.parse(storedCart) : [];
const itemsWithTotals = cart.map(item => ({
...item,
price: parseFloat(item.price) || 0,
quantity: parseInt(item.quantity) || 1,
itemTotal: (parseFloat(item.price) || 0) * (parseInt(item.quantity) || 1)
}));
setCartItems(itemsWithTotals);
const total = itemsWithTotals.reduce((sum, item) => sum + item.itemTotal, 0);
setSubTotal(total);
} catch (error) {
console.error('Error loading cart:', error);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
loadCartData();
window.addEventListener('cartUpdated', loadCartData);
return () => window.removeEventListener('cartUpdated', loadCartData);
}, []);
// Helper to sync and notify
const syncAndNotify = (updatedItems) => {
localStorage.setItem('cart_items', JSON.stringify(updatedItems));
window.dispatchEvent(new Event('cartUpdated'));
};
// Actions
const updateQuantity = (itemId, newQuantity) => {
if (newQuantity <= 0) return handleRemoveItem(itemId);
const updatedItems = cartItems.map(item =>
String(item.id) === String(itemId) ? { ...item, quantity: newQuantity } : item
);
syncAndNotify(updatedItems);
};
const handleRemoveItem = (itemId) => {
const updatedItems = cartItems.filter(item => String(item.id) !== String(itemId));
syncAndNotify(updatedItems);
};
const handleClearCart = () => {
if (window.confirm('Are you sure you want to clear your cart?')) {
syncAndNotify([]);
}
};
const formatCurrency = (amount) => {
return `$${(amount || 0).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
};
return (
<div className="main-wrapper">
<div className="tru-breadcrumb-default ptb--100 ptb_md--50 ptb_sm--30 bg-gradient-1">
<div className="container">
<div className="row">
<div className="col-lg-12">
<div className="breadcrumb-inner text-center">
<h2 className="title">Cart</h2>
<ul className="page-list">
<li className="tru-breadcrumb-item"><Link to="/">Home</Link></li>
<li><div className="icon-right"><i className="feather-chevron-right"></i></div></li>
<li className="tru-breadcrumb-item active">Cart</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<div className="tru-cart-area bg-color-white tru-section-gap">
<div className="container">
<div className="row">
<div className="col-12">
{isLoading ? (
<div className="text-center py-5">
<div className="spinner-border text-primary" role="status"></div>
</div>
) : cartItems.length === 0 ? (
<div className="text-center py-5">
<i className="feather-shopping-cart mb--20 tru-cart-empty-icon"></i>
<h4>Your cart is empty</h4>
<Link to="/explore-courses" className="tru-btn btn-gradient mt--20">Browse Courses</Link>
</div>
) : (
<>
<div className="cart-table table-responsive mb--60">
<table className="table">
<thead>
<tr>
<th className="pro-title">Product</th>
<th className="pro-price">Price</th>
<th className="pro-quantity">Quantity</th>
<th className="pro-subtotal">Total</th>
<th className="pro-remove">Remove</th>
</tr>
</thead>
<tbody>
{cartItems.map((item) => (
<tr key={item.id}>
<td className="pro-title">
<Link to={`/course-details/${item.id}`} className="d-flex align-items-center gap-3">
<img
src={item.image || item.thumbnail || '/assets/images/course/course-01.jpg'}
alt={item.title}
className="tru-cart-thumbnail"
/>
<span className="tru-theme-text">{item.name || item.title}</span>
</Link>
</td>
<td className="pro-price"><span>{formatCurrency(item.price)}</span></td>
<td className="pro-quantity">
<div className="quantity-control justify-content-center">
<button className="qty-btn dec" onClick={() => updateQuantity(item.id, item.quantity - 1)}>
<i className="feather-minus"></i>
</button>
<span className="quantity px-3">{item.quantity}</span>
<button className="qty-btn inc" onClick={() => updateQuantity(item.id, item.quantity + 1)}>
<i className="feather-plus"></i>
</button>
</div>
</td>
<td className="pro-subtotal"><span>{formatCurrency(item.itemTotal)}</span></td>
<td className="pro-remove">
<button onClick={() => handleRemoveItem(item.id)} className="bg-transparent border-0">
<i className="feather-x"></i>
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="row g-5">
<div className="col-lg-6">
<button className="tru-btn btn-border btn-sm" onClick={handleClearCart}>
<i className="feather-trash-2 me-2"></i> Clear Cart
</button>
</div>
<div className="col-lg-5 offset-lg-1">
<div className="cart-summary">
<div className="cart-summary-wrap">
<div className="section-title text-start">
<h4 className="title mb--30">Cart Summary</h4>
</div>
<p>Sub Total <span>{formatCurrency(subTotal)}</span></p>
<p>Shipping Cost <span>$0.00</span></p>
<h2>Grand Total <span>{formatCurrency(subTotal)}</span></h2>
</div>
<div className="cart-submit-btn-group">
<button className="tru-btn btn-gradient w-100" onClick={() => navigate('/checkout')}>
Proceed to Checkout
</button>
</div>
</div>
</div>
</div>
</>
)}
</div>
</div>
</div>
</div>
</div>
);
};
export default CartPage;
import React, { createContext, useContext, useState } from 'react';
const UIContext = createContext();
export const useUI = () => {
const context = useContext(UIContext);
if (!context) {
throw new Error('useUI must be used within UIProvider');
}
return context;
};
export const UIProvider = ({ children }) => {
const [isCartOpen, setIsCartOpen] = useState(false);
const openCart = () => setIsCartOpen(true);
const closeAll = () => setIsCartOpen(false);
return (
<UIContext.Provider value={{
isCartOpen,
openCart,
closeAll
}}>
{children}
</UIContext.Provider>
);
};
cartUpdated custom event to keep all components in sync. When items are added/removed/updated, the event is dispatched and all listening components (Header cart badge, CartSideMenu, CartPage) update automatically.
src/pages/ecommerce/CheckoutPage.jsx
The checkout page implements a multi-gateway payment system with the following features:
cartUpdated event on success
// Payment method state
const [activePaymentMethod, setActivePaymentMethod] = useState('stripe');
// Form states
const [billingForm, setBillingForm] = useState({...});
const [shippingForm, setShippingForm] = useState({...});
// Cart data
const [cartItems, setCartItems] = useState([]);
const [subTotal, setSubTotal] = useState(0);
const [grandTotal, setGrandTotal] = useState(0);
// Payment gateway instances
const [stripe, setStripe] = useState(null);
const [elements, setElements] = useState(null);
// Refs for mounting payment elements
const stripeElementRef = useRef(null);
const paypalButtonRef = useRef(null);
// Stripe payment
const handleStripePayment = async () => {
const { error } = await stripe.confirmPayment({
elements,
confirmParams: {
return_url: `${window.location.origin}/stripe-success`,
payment_method_data: { /* billing details */ }
}
});
};
// Razorpay payment
const handleRazorpayPayment = async () => {
const orderRes = await fetch('/api/payment/create-order', {...});
const options = { /* Razorpay options */ };
const rzp = new window.Razorpay(options);
rzp.open();
};
// PayPal (mounted via useEffect)
useEffect(() => {
if (activePaymentMethod === 'paypal' && window.paypal) {
window.paypal.Buttons({ /* config */ }).render(paypalButtonRef.current);
}
}, [activePaymentMethod]);
const handlePaymentSuccess = () => {
localStorage.removeItem('cart_items');
window.dispatchEvent(new Event('cartUpdated'));
navigate('/student/enrolled-courses');
};
POST /api/payment/stripe/create-intent - Creates Stripe PaymentIntentPOST /api/payment/paypal/create-order - Creates PayPal orderPOST /api/payment/paypal/capture-order - Captures PayPal paymentPOST /api/payment/create-order - Creates Razorpay orderPOST /api/payment/verify - Verifies Razorpay payment| Error Type | HTTP Status | Message | Recovery Action |
|---|---|---|---|
| Invalid Amount | 400 | "Invalid amount" |
Recalculate cart total, ensure amount > 0 |
| Signature Mismatch | 400 | "Transaction not legit!" |
Re-initiate payment, check API keys |
| Payment Failed | 400 | "Payment not successful" |
Retry with different payment method |
| Gateway Error | 500 | "Server error during verification" |
Check gateway configuration, retry later |
| User Not Found | 404 | "User not found" |
Re-authenticate user, check token validity |
| Card Number | Description | Result |
|---|---|---|
4242 4242 4242 4242 |
Visa (success) | Success |
4000 0000 0000 0002 |
Card declined | Declined |
4000 0025 0000 3155 |
3D Secure required | 3D Secure |
Use these test accounts in PayPal sandbox mode:
| Method | Test Details |
|---|---|
| Card | 4111 1111 1111 1111 with any future expiry |
| UPI | Use any test UPI ID |
| Netbanking | Select any bank, use test credentials |
Debug Steps:
// Check server logs for verification errors
console.log('Payment verification debug:');
// 1. Check API keys
console.log('Stripe key exists:', !!process.env.STRIPE_SECRET_KEY);
console.log('Razorpay key exists:', !!process.env.RAZORPAY_KEY_SECRET);
// 2. Test gateway connectivity
// Stripe test
const stripeTest = await stripe.paymentIntents.retrieve('test_id');
console.log('Stripe connection:', stripeTest ? 'OK' : 'Failed');
// 3. Verify signature generation
const expectedSig = crypto.createHmac('sha256', RAZORPAY_SECRET)
.update(orderId + '|' + paymentId)
.digest('hex');
console.log('Expected signature:', expectedSig);
console.log('Received signature:', receivedSignature);
// 4. Check enrollment logic
const user = await User.findById(userId);
console.log('User found:', !!user);
console.log('User enrolled courses:', user.enrolledCourses.length);
Common Causes:
| Symptom | Solution |
|---|---|
| Order document not created | Check Order model import and save() calls |
| User already enrolled | enrollUser() prevents duplicates - check logs |
| Database save error | Check MongoDB connection and permissions |
| Items array empty | Verify cart items are passed correctly |
PAYPAL_MODE=live and use live API credentials // In server.js const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET; app.post('/webhook/stripe', express.raw({type: 'application/json'}), (req, res) => { const sig = req.headers['stripe-signature']; let event; try { event = stripe.webhooks.constructEvent(req.body, sig, endpointSecret); } catch (err) { console.error('Webhook signature verification failed:', err.message); return res.status(400).send(`Webhook Error: ${err.message}`); } // Handle the event switch (event.type) { case 'payment_intent.succeeded': const paymentIntent = event.data.object; // Update order status, send confirmation email, etc. break; case 'payment_intent.payment_failed': // Handle failed payment break; default: console.log(`Unhandled event type ${event.type}`); } res.json({received: true}); });
TruLern uses a normalized MongoDB schema design with Mongoose. Below are the reference definitions for all core collections.
ObjectId is a unique MongoDB
identifier used to create relationships between documents (e.g., linking a Course to an Instructor).
Stores all user accounts including students, instructors, and administrators.
| Field | Type | Description |
|---|---|---|
firstName |
String | User's first name |
lastName |
String | User's last name |
email |
String | Unique email address (login username) |
password |
String | Bcrypt hashed password |
role |
String | Enum: ['student', 'instructor', 'admin'] |
avatar |
String | Path to profile picture |
enrolledCourses |
Array | List of objects: { course: ObjectId, progress: Number, status: String } |
wishlist |
Array | Array of Course ObjectIds |
purchasedAssessments |
Array | List of standalone assessments purchased by the user |
The central collection containing course metadata and the nested curriculum.
| Field | Type | Description |
|---|---|---|
title |
String | Course title |
slug |
String | URL-friendly unique identifier |
instructor |
ObjectId | Reference to User |
price |
Number | Current selling price (0 for free) |
thumbnail |
String | Path to course cover image |
status |
String | Enum: ['Draft', 'Pending', 'Published'] |
episodes |
Array | Curriculum Tree: Array of Topic objects containing nested
lessons and quizzes.
|
certificateTemplate |
String | ID of the PDF template to use |
Stored inside course.episodes.
| Field | Type | Description |
|---|---|---|
title |
String | Lesson title |
lessonType |
String | Enum: ['video', 'reading'] |
videoSource |
String | Enum: ['vimeo', 'youtube', 'local'] |
videoUrl / videoPath |
String | External URL or local file path |
articleBody |
String | HTML content for reading lessons |
exerciseFiles |
Array | List of file objects: { filename, path, size } |
isPreview |
Boolean | Allow free access without enrollment |
Digital library items uploaded by instructors.
| Field | Type | Description |
|---|---|---|
title |
String | Resource title |
category |
String | Main category |
subCategory |
String | Sub-category |
instructor |
ObjectId | Owner of the resource |
file |
String | Path to the downloadable file (PDF/Zip) |
thumbnail |
String | Path to preview image |
Financial transaction records.
| Field | Type | Description |
|---|---|---|
user |
ObjectId | Buyer |
course |
ObjectId | Purchased Item |
amount |
Number | Final paid amount |
paymentGateway |
String | 'stripe', 'paypal', or 'razorpay' |
transactionId |
String | Gateway reference ID |
status |
String | 'pending', 'success', 'failed' |
Course ratings and feedback.
| Field | Type | Description |
|---|---|---|
user |
ObjectId | Reviewer |
course |
ObjectId | Target Course |
rating |
Number | 1-5 Star Rating |
review |
String | Text feedback |
Instructor-to-student broadcasts.
| Field | Type | Description |
|---|---|---|
instructor |
ObjectId | Sender |
course |
ObjectId | Target Course (optional if global) |
subject |
String | Email subject |
message |
String | HTML message body |
attachment |
Object | { filename, path } |
TruLern uses a modern React component-based architecture with Vite as the build tool. The application follows a feature-based organization with reusable components, contexts for state management, and page-level routing.
Ctrl+P (or Cmd+P) in VS Code to quickly jump to these files by name.
avatars/ // Profile pictureslogos/ // Course logos for certificatesthumbnails/ // Course cover imagesvideos/ // Local video uploadspdfs/ // Exercise files_variables.scss // Global colors, fonts, spacingmain.scss // Main compilation file_course.scss, _quiz.scss, _lesson.scss, _buttons.scss, etc.CurriculumBuilder.jsx // Main curriculum interfaceTopicModal.jsx // Add/edit topicsLessonModal.jsx // Add/edit lessonsQuizModal.jsx // Add/edit quizzesCartSideMenu.jsx // Slide-out cart drawerLayout.jsx // Main layout with header/footerDashboardLayout.jsx // Dashboard layout with sidebarPrivateRoute.jsx // Route protection componentAuthContext.jsx // User authentication stateUIContext.jsx // UI state (cart sidebar, modals)ModeContext.jsx // Light/dark mode preferenceComponentInitializer.jsx // Global component setupCourseDetails.jsx // Public course pageExploreCourses.jsx // Course listing with filtersCart.jsx // Shopping cart pageCheckoutPage.jsx // Multi-gateway checkoutInstructorDashboard.jsx // Instructor homeCreateCourse.jsx // Step 1: Basic infoEditCourse.jsx // Step 2: Full course builderInstructorCourses.jsx // Course listingLessonPage.jsx // Video/reading lesson playerQuizResultPage.jsx // Quiz results displayStudentDashboard.jsx // Student homeStudentEnrolledCourses.jsx // My courses with progressStudentWishlist.jsx // Saved coursesLogin.jsx // Login pageInstructorRegisterPage.jsx // Instructor signupIndexDemo.jsx // Landing page demoapi.js // Axios configuration and interceptorsDOMSyncManager.jsx // Legacy support utilitiesEvery visible page in the application maps to a React component in the src/pages/ directory.
| Page URL | Component | Description |
|---|---|---|
/ or /demo |
IndexDemo.jsx |
Main landing page with multiple homepage variations |
/login |
Login.jsx |
User login form with JWT authentication |
/instructor-register |
InstructorRegisterPage.jsx |
Instructor registration form |
/reset-password |
ResetPassword.jsx |
Password reset form with token validation |
/explore-courses |
ExploreCourses.jsx |
Course catalog with filtering and search |
/course-details/:id |
CourseDetails.jsx |
Public course landing page |
/lesson/:courseId |
LessonPage.jsx |
Video/reading lesson player (standalone layout) |
/instructor/dashboard |
InstructorDashboard.jsx |
Instructor dashboard with analytics |
/instructor/create-course |
CreateCourse.jsx |
Step 1: Basic course info and thumbnail |
/instructor/edit-course/:courseId |
EditCourse.jsx |
Step 2: Full course builder with curriculum |
/student/dashboard |
StudentDashboard.jsx |
Student dashboard overview |
/student/enrolled-courses |
StudentEnrolledCourses.jsx |
My courses with progress tracking and certificate download |
/cart |
Cart.jsx |
Shopping cart page with quantity controls |
/checkout |
CheckoutPage.jsx |
Multi-gateway checkout (Stripe, PayPal, Razorpay) |
/stripe-success |
StripeSuccess.jsx |
Post-payment success handler |
/resources |
ResourcesPage.jsx |
Digital library of resources |
/design-system-* |
Various in pages/design-system/ |
UI component reference pages (buttons, forms, cards, etc.) |
The application uses React Router v6 with nested layouts. The routing structure is defined in App.jsx.
// Russian Doll Nested Layouts
<Router>
<AuthProvider>
<UIProvider>
<ModeProvider>
<ComponentInitializer>
<Routes>
{/* Standalone pages (no header/footer) */}
<Route path="/lesson/:courseId" element={<LessonPage />} />
{/* Main website layout with header/footer */}
<Route element={<Layout />}>
<Route path="/" element={<IndexDemo />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/explore-courses" element={<ExploreCourses />} />
{/* Dashboard routes with sidebar */}
<Route path="/instructor" element={<DashboardLayout />}>
<Route path="dashboard" element={<InstructorDashboard />} />
<Route path="courses" element={<InstructorCourses />} />
</Route>
<Route path="/student" element={<DashboardLayout />}>
<Route path="dashboard" element={<StudentDashboard />} />
<Route path="enrolled-courses" element={<StudentEnrolledCourses />} />
</Route>
</Route>
</Routes>
</ComponentInitializer>
</ModeProvider>
</UIProvider>
</AuthProvider>
</Router>
Global state is managed through React Context providers located in src/context/.
| Context | File | Purpose |
|---|---|---|
| AuthContext | AuthContext.jsx |
Manages user authentication state, login/logout, token storage, and role-based access |
| UIContext | UIContext.jsx |
Controls UI state like cart sidebar visibility, modals, and global UI toggles |
| ModeContext | ModeContext.jsx |
Handles light/dark mode preference and persistence in localStorage |
| ComponentInitializer | ComponentInitializer.jsx |
Initializes global components like the shadow mode alert and syncs with legacy systems |
The backend follows a clean MVC pattern and is completely separate from the React frontend.
User.js // Auth, profile, role, wishlist, enrollments.Course.js // Metadata + Curriculum (Episodes/Lessons/Quizzes).Order.js // Financial transaction records.Review.js, Announcement.js, Resource.jsAssessment.js, QuizResult.js, Book.jsauthRoutes.js // Login, Register, Profile, Password Reset.courseRoutes.js // Course CRUD, Public Listing, Filtering.paymentRoutes.js // Stripe, PayPal, and Razorpay logic.course-1.json to course-10.json // JSON data used by the installer script.template-blank.pdf // The base PDF used for generating dynamic certificates.CormorantUnicase-Light.ttf // Custom font embedded into generated certificates.fetchWithAuth utility from AuthContext or the api.js service with Axios interceptors.
TruLern uses a powerful, modular SCSS architecture integrated with Vite. You can customize the entire visual theme by modifying the variable configuration.
Navigate to src/assets/scss/_variables.scss. This file controls global settings for colors, typography, spacing, and component behaviors.
// Brand Colors $primary-color: #2f57ef; /* Main brand color (Buttons, Links, Accents) */ $secondary-color: #b12add; /* Secondary accent */ $heading-color: #393939; /* H1-H6 color */ $body-color: #525252; /* Paragraph text color */ // Fonts $font-primary: 'Inter', sans-serif; // Layout $container-width: 1200px; $spacing-unit: 8px; // Component Specific $border-radius: 8px; $box-shadow: 0 5px 20px rgba(0,0,0,0.05);
Vite handles SCSS compilation automatically:
| Mode | Command | Behavior |
|---|---|---|
| Development | npm run dev |
Vite compiles SCSS on-the-fly with hot module replacement. Changes appear instantly. |
| Production | npm run build |
Vite compiles and minifies SCSS into the final dist/ folder. |
src/assets/scss/
├── _variables.scss # Global variables (colors, fonts, spacing)
├── main.scss # Main compilation file (imports all others)
└── components/ # 80+ modular component files
├── _buttons.scss # All button variations
├── _forms.scss # Form inputs, selects, checkboxes
├── _course.scss # Course cards and details
├── _course-builder.scss # Curriculum builder specific
├── _lesson.scss # Lesson player
├── _quiz.scss # Quiz interface
├── _cart.scss # Shopping cart
├── _checkout.scss # Checkout forms
├── _modals.scss # Modal windows
└── ... 70+ more files
To add custom styles for a specific component:
Add styles to the appropriate component file in components/ directory.
// In src/assets/scss/components/_custom-component.scss
.my-custom-component {
background: $primary-color;
padding: $spacing-unit * 3;
.custom-title {
color: white;
font-size: 1.5rem;
}
}
// Then import in main.scss
// @import 'components/custom-component';
Create a CSS module alongside your component for scoped styles.
// src/components/MyComponent.module.scss
.root {
padding: 20px;
}
.title {
color: $primary-color;
}
// In your component
import styles from './MyComponent.module.scss';
function MyComponent() {
return (
<div className={styles.root}>
<h2 className={styles.title}>Hello</h2>
</div>
);
}
Vite's SCSS configuration is in vite.config.js:
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
css: {
preprocessorOptions: {
scss: {
additionalData: `@import "src/assets/scss/_variables.scss";`,
},
},
},
});
additionalData setting automatically imports variables into every SCSS file, so you don't need to manually @import '_variables' in each component.
From package.json in the client directory:
{
"scripts": {
"dev": "vite", // Start dev server with live SCSS compilation
"build": "vite build", // Build for production (compiles & minifies SCSS)
"preview": "vite preview" // Preview production build locally
}
}
npm run dev to see changes instantly, or npm run build when you're ready to deploy.
The styling is organized into modular components found in src/assets/scss/components/. Use this reference to find exactly which file to edit for any UI element.
npm run dev. For production, run npm run build.
| Category | SCSS Files (in src/assets/scss/components/) |
|---|---|
| Core UI & Typography |
_buttons.scss, _typography.scss, _colors.scss,
_common.scss,
_reset-styles.scss, _utilities.scss, _spacing.scss,
_bgimage.scss,
_bg-color-darker.scss, _background-gradient.scss
|
| Layout & Navigation |
_header.scss, _footer.scss, _mobile-menu.scss,
_sidebar-styles.scss,
_sidebar-widgets.scss, _inner-page-layout.scss,
_copyright.scss, _back-to-top.scss,
_section.scss, _container.scss
|
| Courses & Learning |
_course.scss, _course-details.scss,
_course-sidebar.scss, _course-features.scss,
_course-shapes.scss, _lesson.scss, _video.scss,
_edit-course.scss,
_assessment.scss, _certificate-template.scss,
_quiz.scss, _course-builder.scss
|
| Dashboards |
_instructor_dashboard.scss, _my-account.scss,
_progress.scss, _tables.scss,
_counter.scss, _list.scss
|
| Shop & Cart |
_shop.scss, _cart.scss, _mini-cart.scss,
_cart-summary.scss,
_checkout.scss, _pricing.scss
|
| UI Elements |
_accordion.scss, _tabs.scss, _modals.scss,
_badge.scss,
_blockquote.scss, _social.scss, _pagination.scss,
_search.scss,
_filter-select.scss, _dropdowns.scss, _forms.scss,
_show-more.scss
|
| Marketing Sections |
_banner.scss, _banner-core.scss, _banner-variants.scss,
_banner-components.scss,
_testimonial.scss, _service.scss, _service-area.scss,
_splash-service-area.scss,
_team.scss, _blog.scss, _portfolio.scss,
_gallery.scss,
_brand-styles.scss, _brand-assets.scss, _cta.scss,
_cta-footer.scss,
_about.scss, _contact.scss
|
| Advanced & Plugins |
_animation.scss, _dark-mode.scss, _overlays.scss,
_swiperslider.scss,
_slider-default.scss, _image-content-scroller.scss,
_feature.scss, _feature-plugin.scss,
_feature-presentation.scss, _layout-presentation.scss,
_single-demo.scss, _switcher.scss,
_hotfix.scss, _extra.scss, _event-shapes.scss
|
To change the appearance of all buttons across the application:
src/assets/scss/components/_buttons.scss.tru-btn-primary)
// In _buttons.scss
.tru-btn-primary {
background: $primary-color;
border-radius: 30px; // Change from 8px to 30px
padding: 15px 30px; // Increase padding
font-weight: 600; // Make text bolder
&:hover {
background: darken($primary-color, 10%);
transform: translateY(-2px);
}
}
Changes will appear immediately in your development server.
$primary-color) defined in _variables.scss rather than hardcoding color values. This ensures theme consistency and makes future updates easier.
TruLern uses React Router for client-side routing. Adding a new page involves creating a React component and adding a route in App.jsx.
Create a new React component in the appropriate folder inside src/pages/:
import React from 'react';
import { Link } from 'react-router-dom';
const Webinar = () => {
return (
<div className="webinar-page">
<h1>Upcoming Webinars</h1>
<div className="webinar-list">
{/* Your page content here */}
</div>
</div>
);
};
export default Webinar;
Open src/App.jsx and add your new route inside the appropriate layout:
// 1. Import your new component
import Webinar from './pages/Webinar';
// 2. Add the route inside the Layout wrapper
<Route element={<Layout />}>
{/* Existing routes */}
<Route path="/webinar" element={<Webinar />} />
</Route>
Add a link to your new page using React Router's Link component:
import { Link } from 'react-router-dom';
// In your navigation component
<li>
<Link to="/webinar">Webinars</Link>
</li>
If your page needs specific logic, you can:
src/hooks/ for reusable logic
import React, { useState, useEffect } from 'react';
import { useAuth } from '../context/AuthContext';
const Webinar = () => {
const [webinars, setWebinars] = useState([]);
const [loading, setLoading] = useState(true);
const { fetchWithAuth } = useAuth();
useEffect(() => {
const loadWebinars = async () => {
try {
const response = await fetchWithAuth('/api/webinars');
const data = await response.json();
setWebinars(data.webinars);
} catch (error) {
console.error('Failed to load webinars:', error);
} finally {
setLoading(false);
}
};
loadWebinars();
}, []);
if (loading) return <div>Loading...</div>;
return (
<div>
{webinars.map(webinar => (
<div key={webinar.id}>{webinar.title}</div>
))}
</div>
);
};
Add custom styles in the appropriate SCSS file:
src/assets/scss/components/_webinar.scss)main.scss if you created a new file:
// In src/assets/scss/main.scss
@import 'components/webinar';
<Layout /> route. For pages without header/footer (like lesson player), place it outside the Layout wrapper in App.jsx.
| Route Type | Placement in App.jsx | Example |
|---|---|---|
| With Header & Footer | Inside <Route element={<Layout />}> |
/webinar, /about, /contact |
| With Dashboard Sidebar | Inside <Route element={<DashboardLayout />}> |
/instructor/*, /student/* |
| Standalone (No Layout) | Outside any layout wrapper | /lesson/:courseId |
TruLern follows the standard MVC (Model-View-Controller) pattern. To add a new feature (e.g., a "Ticket" system for support), follow these steps:
Create backend/models/Ticket.js:
const mongoose = require('mongoose');
const ticketSchema = new mongoose.Schema({
subject: String,
message: String,
user: { type: mongoose.Schema.Types.ObjectId, ref: 'User' },
status: { type: String, enum: ['Open', 'Closed'], default: 'Open' },
createdAt: { type: Date, default: Date.now }
});
module.exports = mongoose.model('Ticket', ticketSchema);
Create backend/routes/ticketRoutes.js:
const express = require('express');
const router = express.Router();
const Ticket = require('../models/Ticket');
const auth = require('../authMiddleware');
// Get all tickets
router.get('/', auth, async (req, res) => {
const tickets = await Ticket.find({ user: req.user.id });
res.json(tickets);
});
// Create ticket
router.post('/', auth, async (req, res) => {
const newTicket = new Ticket({ ...req.body, user: req.user.id });
await newTicket.save();
res.json(newTicket);
});
module.exports = router;
Open backend/server.js and add this line where other routes are defined:
app.use('/api/tickets', require('./routes/ticketRoutes'));
Once the backend endpoint is created, you can call it from any React component using the fetchWithAuth method from AuthContext or the Axios instance in src/services/api.js:
import React, { useState, useEffect } from 'react';
import { useAuth } from '../../context/AuthContext';
const TicketsPage = () => {
const [tickets, setTickets] = useState([]);
const { fetchWithAuth } = useAuth();
useEffect(() => {
const loadTickets = async () => {
const response = await fetchWithAuth('/api/tickets');
const data = await response.json();
setTickets(data);
};
loadTickets();
}, []);
return (
<div>
{tickets.map(ticket => (
<div key={ticket._id}>{ticket.subject}</div>
))}
</div>
);
};
TruLern includes Stripe, PayPal, and Razorpay. To add a custom gateway (e.g., Paystack, Flutterwave), follow this architecture.
In src/pages/ecommerce/CheckoutPage.jsx, add your new gateway to the gateways array and create a corresponding section in the JSX.
// In the gateways array
const gateways = [
{ id: 'stripe', name: 'Stripe', icon: '/assets/images/payment/stripe.svg' },
{ id: 'paypal', name: 'PayPal', icon: '/assets/images/payment/paypal.svg' },
{ id: 'razorpay', name: 'Razorpay', icon: '/assets/images/payment/razorpay.svg' },
{ id: 'paystack', name: 'Paystack', icon: '/assets/images/payment/paystack.svg' } // New
];
// In the payment sections container, add:
<div id="paystack-section" className={`payment-section ${activePaymentMethod === 'paystack' ? 'active' : ''}`}>
<p className="text-muted mb-3">
Pay with Paystack (Cards, Bank Transfer, Mobile Money)
</p>
<button
className="tru-btn hover-icon-reverse btn-border-gradient radius-round w-100 mt-3"
onClick={() => handlePayment('paystack')}
disabled={cartItems.length === 0}
>
<div className="icon-reverse-wrapper">
<span className="btn-text">Pay with Paystack</span>
<span className="btn-icon"><i className="feather-credit-card"></i></span>
<span className="btn-icon"><i className="feather-credit-card"></i></span>
</div>
</button>
</div>
In CheckoutPage.jsx, add a handler function for your new gateway:
const handlePaystackPayment = async () => {
const formData = getFormData();
if (!formData) return;
const token = getLocalStorageItem('lmsToken');
try {
// 1. Initialize payment on your backend
const initResponse = await fetch('/api/payment/paystack/initialize', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-auth-token': token
},
body: JSON.stringify({
amount: grandTotal,
email: formData.billing.email,
items: cartItems,
metadata: {
billing: formData.billing,
shipping: showShipping ? shippingForm : formData.billing
}
})
});
const initData = await initResponse.json();
if (!initData.success) {
throw new Error(initData.message);
}
// 2. Open Paystack popup
const handler = window.PaystackPop.setup({
key: process.env.REACT_APP_PAYSTACK_PUBLIC_KEY,
email: formData.billing.email,
amount: Math.round(grandTotal * 100),
currency: 'GHS', // or your currency
reference: initData.reference,
metadata: initData.metadata,
callback: async (response) => {
// 3. Verify payment on backend
const verifyResponse = await fetch('/api/payment/paystack/verify', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-auth-token': token
},
body: JSON.stringify({
reference: response.reference,
items: cartItems,
totalAmount: grandTotal
})
});
const verifyData = await verifyResponse.json();
if (verifyData.success) {
handlePaymentSuccess();
} else {
alert('Payment verification failed: ' + verifyData.message);
}
},
onClose: () => {
console.log('Payment window closed');
}
});
handler.openIframe();
} catch (error) {
console.error('Paystack payment error:', error);
alert('Payment error: ' + error.message);
}
};
// Add to the main handlePayment function
const handlePayment = async (method) => {
if (!acceptTerms) {
alert('Please accept the terms & conditions to proceed.');
return;
}
if (cartItems.length === 0) {
alert('Your cart is empty!');
return;
}
switch (method) {
case 'stripe':
await handleStripePayment();
break;
case 'paypal':
await handlePayPalPayment();
break;
case 'razorpay':
await handleRazorpayPayment();
break;
case 'paystack':
await handlePaystackPayment(); // New
break;
default:
console.error('Unknown payment method:', method);
}
};
In backend/routes/paymentRoutes.js, add initialization and verification endpoints that use the shared enrollUser() helper.
// Paystack initialization
router.post('/paystack/initialize', auth, async (req, res) => {
try {
const { amount, email, items, metadata } = req.body;
// Initialize with Paystack API
const response = await axios.post('https://api.paystack.co/transaction/initialize', {
email,
amount: Math.round(amount * 100),
currency: 'GHS',
metadata: {
...metadata,
userId: req.user.id,
items
}
}, {
headers: {
Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}`,
'Content-Type': 'application/json'
}
});
res.json({
success: true,
reference: response.data.data.reference,
metadata: response.data.data.metadata
});
} catch (error) {
console.error('Paystack init error:', error);
res.status(500).json({ success: false, message: 'Failed to initialize payment' });
}
});
// Paystack verification
router.post('/paystack/verify', auth, async (req, res) => {
try {
const { reference, items, totalAmount } = req.body;
// Verify with Paystack API
const response = await axios.get(`https://api.paystack.co/transaction/verify/${reference}`, {
headers: {
Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}`
}
});
const paymentData = response.data.data;
if (paymentData.status === 'success') {
// Use the shared enrollUser helper
const enrollmentSuccess = await enrollUser(req.user.id, items);
if (enrollmentSuccess) {
// Create order records
await Promise.all(items.map(item =>
new Order({
user: req.user.id,
course: item.id,
amount: item.price,
status: 'success',
paymentGateway: 'paystack',
transactionId: reference
}).save()
));
return res.json({ success: true });
}
}
res.status(400).json({ success: false, message: 'Payment verification failed' });
} catch (error) {
console.error('Paystack verify error:', error);
res.status(500).json({ success: false, message: 'Verification error' });
}
});
Add your gateway credentials to .env files:
# Paystack (example)
PAYSTACK_SECRET_KEY=sk_live_xxxxxxxxxxxxxxxxxxxx
PAYSTACK_PUBLIC_KEY=pk_live_xxxxxxxxxxxxxxxxxxxx
VITE_PAYSTACK_PUBLIC_KEY=pk_live_xxxxxxxxxxxxxxxxxxxx
enrollUser() helper for enrollment logic to maintain consistency across all payment gateways. Never duplicate enrollment code.
getFormData()enrollUser()TruLern is optimized for deployment on any VPS (Virtual Private Server) running Ubuntu/Linux, such as DigitalOcean, AWS EC2, or Linode. The application consists of two separate parts: the React frontend and the Node.js backend API.
npm install -g pm2Upload your project files to the server. Recommended structure:
/var/www/trulern/
├── backend/ # Node.js API server
└── client/ # React frontend source (will be built to /var/www/trulern/html)
Navigate to the backend directory and install dependencies:
cd /var/www/trulern/backend
npm install --production
Create environment configuration file:
NODE_ENV=production
PORT=5000
MONGO_URI=mongodb+srv://username:password@cluster.mongodb.net/trulern
JWT_SECRET=your_secure_random_string_here
# Email Configuration
EMAIL_HOST=smtp.gmail.com
EMAIL_PORT=587
EMAIL_USER=your-email@gmail.com
EMAIL_PASS=your-app-password
# Payment Gateway Keys
STRIPE_SECRET_KEY=sk_live_...
PAYPAL_CLIENT_ID=AY...
RAZORPAY_KEY_ID=rzp_live...
RAZORPAY_KEY_SECRET=...
Navigate to the client directory, install dependencies, and create production build:
cd /var/www/trulern/client
npm install
Create frontend environment file:
VITE_API_URL=https://your-domain.com/api # Or http://localhost:5000/api if using same server
VITE_STRIPE_PUBLISHABLE_KEY=pk_live_...
VITE_RAZORPAY_KEY_ID=rzp_live_...
Build the React application:
npm run build
This creates a dist/ folder with static files that will be served by Nginx.
Use PM2 to keep your Node.js backend running continuously:
cd /var/www/trulern/backend
pm2 start server.js --name trulern-api
pm2 save
pm2 startup # Follow the instructions to enable PM2 on system restart
Nginx will serve both the React frontend (static files) and proxy API requests to your Node.js backend.
server {
listen 80;
server_name your-domain.com www.your-domain.com;
# Root directory for React static files
root /var/www/trulern/client/dist;
index index.html;
# Gzip compression
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
# API requests - proxy to backend
location /api/ {
proxy_pass http://localhost:5000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Uploaded files
location /uploads/ {
alias /var/www/trulern/client/public/uploads/;
expires 30d;
add_header Cache-Control "public, no-transform";
}
# All other routes - serve React app (for client-side routing)
location / {
try_files $uri $uri/ /index.html;
expires 1h;
add_header Cache-Control "public, no-transform";
}
}
# Create symbolic link
sudo ln -s /etc/nginx/sites-available/trulern /etc/nginx/sites-enabled/
# Test Nginx configuration
sudo nginx -t
# Reload Nginx
sudo systemctl reload nginx
Use Let's Encrypt to secure your site with free SSL certificates:
# Install Certbot
sudo apt update
sudo apt install certbot python3-certbot-nginx
# Obtain SSL certificate
sudo certbot --nginx -d your-domain.com -d www.your-domain.com
# Certbot will automatically update your Nginx configuration for HTTPS
# Certificates auto-renew, test with:
sudo certbot renew --dry-run
Ensure upload directories exist and have correct permissions:
cd /var/www/trulern/client/public
mkdir -p uploads/{avatars,covers,logos,pdfs,resources,thumbnails,videos}
# Set permissions (Nginx runs as www-data)
sudo chown -R www-data:www-data uploads/
sudo chmod -R 755 uploads/
https://your-domain.com - Should load the React apphttps://your-domain.com/api/courses - Should return JSONpm2 logs trulern-apiTruLern uses PM2 to keep your Node.js backend API running 24/7. The React frontend is served by Nginx (static files) and does not need PM2.
sudo npm install pm2 -g
Navigate to the backend folder where your server.js is located.
cd /var/www/trulern/backend pm2 start server.js --name trulern-api
This starts the Node.js backend server. You can also use the pre-configured ecosystem file if available:
pm2 start ecosystem.config.js
View logs, status, and resource usage:
# List all running processes pm2 list # View real-time logs pm2 logs trulern-api # Monitor CPU/Memory usage pm2 monit # Restart the application pm2 restart trulern-api # Stop the application pm2 stop trulern-api
Ensure the backend API restarts automatically if the server reboots.
# Save the current process list pm2 save # Generate startup script pm2 startup # Follow the on-screen instructions (usually just run the command it outputs)
Example output and command to run:
[PM2] To setup the Startup Script, copy/paste the following command: sudo env PATH=$PATH:/usr/bin pm2 startup systemd -u youruser --hp /home/youruser
If you have multiple instances or need advanced configuration, create ecosystem.config.js in your backend folder:
module.exports = {
apps: [{
name: 'trulern-api',
script: 'server.js',
instances: 1, // Set to 'max' for cluster mode
exec_mode: 'fork', // or 'cluster'
watch: false,
max_memory_restart: '1G',
env: {
NODE_ENV: 'production',
PORT: 5000
},
error_file: './logs/err.log',
out_file: './logs/out.log',
log_file: './logs/combined.log',
time: true
}]
};
Then start with: pm2 start ecosystem.config.js
pm2 restart trulern-api
curl http://localhost:5000/api/courses
# Should return JSON response
Nginx serves two purposes: it serves your built React frontend static files, and acts as a reverse proxy to forward API requests to your Node.js backend running on port 5000.
/var/www/trulern/client/dist/api/) → Proxied to Node.js backend on port 5000/var/www/trulern/client/public/uploadsCreate a configuration file for your site.
sudo nano /etc/nginx/sites-available/trulern
Paste the following configuration (replace your-domain.com with your actual domain):
server {
listen 80;
server_name your-domain.com www.your-domain.com;
# Root directory for React built files
root /var/www/trulern/client/dist;
index index.html;
# Gzip compression for faster loading
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json;
# API requests - proxy to Node.js backend
location /api/ {
proxy_pass http://localhost:5000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Increase timeout for file uploads
proxy_connect_timeout 300s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
}
# Serve uploaded files directly
location /uploads/ {
alias /var/www/trulern/client/public/uploads/;
expires 30d;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
# Handle static assets with cache headers
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
# All other routes - serve React app (for client-side routing)
location / {
try_files $uri $uri/ /index.html;
expires 1h;
add_header Cache-Control "public, no-transform";
}
# Deny access to hidden files
location ~ /\. {
deny all;
access_log off;
log_not_found off;
}
}
Enable the site and test configuration:
# Create symbolic link to enable site sudo ln -s /etc/nginx/sites-available/trulern /etc/nginx/sites-enabled/ # Test Nginx configuration for syntax errors sudo nginx -t # Reload Nginx to apply changes sudo systemctl reload nginx
Secure your site with a free SSL certificate from Let's Encrypt. This will automatically update your Nginx configuration.
sudo apt update sudo apt install certbot python3-certbot-nginx
Run Certbot with the Nginx plugin. It will automatically modify your Nginx configuration to use HTTPS.
sudo certbot --nginx -d your-domain.com -d www.your-domain.com
Follow the interactive prompts:
After completion, Certbot will generate a configuration like this:
server {
listen 443 ssl;
server_name your-domain.com;
ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;
# ... rest of your configuration ...
}
server {
listen 80;
server_name your-domain.com;
return 301 https://$host$request_uri; # Redirect HTTP to HTTPS
}
Let's Encrypt certificates are valid for 90 days. Certbot automatically sets up a cron job for renewal. Test it:
sudo certbot renew --dry-run
This should run without errors, confirming that auto-renewal is working.
After configuration, test your setup:
# Check if frontend loads curl -I https://your-domain.com # Test API endpoint curl https://your-domain.com/api/courses # Check SSL certificate openssl s_client -connect your-domain.com:443 -servername your-domain.com 2>/dev/null | openssl x509 -noout -dates
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw reload
pm2 listcurl http://localhost:5000/api/coursesOptimize your TruLern installation for better performance, especially when handling large video files and high traffic.
If you face errors uploading large video files (default limit is usually 1MB), update your Nginx configuration:
http {
...
client_max_body_size 500M; # Allow uploads up to 500MB
client_body_timeout 300s; # Increase timeout for large uploads
...
}
Also update the proxy timeouts in your site configuration:
location /api/ {
proxy_pass http://localhost:5000;
proxy_connect_timeout 300s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
}
After changes, reload Nginx: sudo systemctl reload nginx
Compress text-based assets (HTML, CSS, JS, JSON) to reduce bandwidth and improve load times. Add to your Nginx config:
http {
...
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_comp_level 6;
gzip_types
text/plain
text/css
text/xml
text/javascript
application/javascript
application/json
application/xml+rss
application/x-javascript
application/xml
application/xhtml+xml
application/rss+xml
application/atom+xml
font/truetype
font/opentype
image/svg+xml;
...
}
Add cache headers to your Nginx configuration to leverage browser caching:
# Cache static assets for 1 year
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Cache images for 30 days
location ~* \.(jpg|jpeg|png|gif|ico|webp|svg)$ {
expires 30d;
add_header Cache-Control "public, no-transform";
}
# Cache CSS/JS for 7 days
location ~* \.(css|js)$ {
expires 7d;
add_header Cache-Control "public, no-transform";
}
Optimize your Node.js backend with cluster mode and memory limits:
module.exports = {
apps: [{
name: 'trulern-api',
script: 'server.js',
instances: 'max', // Use all CPU cores
exec_mode: 'cluster', // Enable cluster mode
max_memory_restart: '1G', // Restart if memory exceeds 1GB
node_args: '--max-old-space-size=1024', // Node.js memory limit
env: {
NODE_ENV: 'production',
PORT: 5000
},
error_file: './logs/err.log',
out_file: './logs/out.log',
combine_logs: true,
time: true
}]
};
Ensure MongoDB indexes are properly set for frequently queried fields:
# Check existing indexes
db.courses.getIndexes()
# Create indexes for common queries
db.courses.createIndex({ status: 1, createdAt: -1 })
db.courses.createIndex({ instructor: 1, status: 1 })
db.courses.createIndex({ slug: 1 }, { unique: true })
db.courses.createIndex({ title: "text", description: "text" })
db.users.createIndex({ email: 1 }, { unique: true })
db.users.createIndex({ role: 1 })
db.users.createIndex({ 'enrolledCourses.course': 1 })
When building the React frontend, Vite automatically optimizes the bundle. You can further optimize with:
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
build: {
rollupOptions: {
output: {
manualChunks: {
vendor: ['react', 'react-dom', 'react-router-dom'],
stripe: ['@stripe/stripe-js', '@stripe/react-stripe-js'],
ui: ['react-select', '@tinymce/tinymce-react']
}
}
},
chunkSizeWarningLimit: 1000,
minify: 'terser',
terserOptions: {
compress: {
drop_console: true, // Remove console.log in production
drop_debugger: true
}
}
}
});
For high-traffic sites, consider using a CDN like Cloudflare or Amazon CloudFront to serve static assets:
With a CDN, update your asset URLs in the React build to point to the CDN endpoint.
Data loss can happen due to server failure or accidental deletion. A robust backup strategy involves securing your Database (MongoDB) and your User Files (Uploads).
/var/www/trulern/client/public/uploads/ (avatars, thumbnails, videos, PDFs)Use the standard MongoDB utility to create a snapshot of your data.
# Syntax: mongodump --uri="YOUR_MONGO_URI" --out=/path/to/backup # Example (local MongoDB): mongodump --db=trulern --out=./backups/$(date +%F) # Example (MongoDB Atlas): mongodump --uri="mongodb+srv://username:password@cluster.mongodb.net/trulern" --out=./backups/$(date +%F)
To restore a database from a previous backup:
# Syntax: mongorestore --uri="YOUR_MONGO_URI" /path/to/backup/folder # Example (local): mongorestore --db=trulern ./backups/2024-12-31/trulern # Example (Atlas): mongorestore --uri="mongodb+srv://username:password@cluster.mongodb.net/trulern" ./backups/2024-12-31/trulern
User-uploaded content (avatars, course thumbnails, lesson videos, exercise PDFs) lives in the file system. These must be backed up separately from the database.
/var/www/trulern/client/public/uploads/
# Navigate to project root cd /var/www/trulern # Create a tar archive of all uploads (preserves permissions) tar -czf uploads-backup-$(date +%F).tar.gz client/public/uploads/ # Or create a zip archive zip -r uploads-backup-$(date +%F).zip client/public/uploads/
You can automate this process using a simple shell script and Cron.
Create a file named /home/username/backup-trulern.sh:
#!/bin/bash
# Configuration
DATE=$(date +%Y-%m-%d)
BACKUP_DIR="/root/backups/trulern/$DATE"
PROJECT_ROOT="/var/www/trulern"
MONGO_URI="mongodb+srv://username:password@cluster.mongodb.net/trulern" # Update this!
# Create backup directory
mkdir -p $BACKUP_DIR/{db,files}
echo "[$(date)] Starting TruLern backup..."
# 1. Backup Database
echo "Backing up MongoDB..."
mongodump --uri="$MONGO_URI" --out=$BACKUP_DIR/db &>/dev/null
if [ $? -eq 0 ]; then
echo "✓ Database backup successful"
else
echo "✗ Database backup FAILED"
fi
# 2. Backup Uploaded Files
echo "Backing up uploaded files..."
cp -r $PROJECT_ROOT/client/public/uploads $BACKUP_DIR/files/
if [ $? -eq 0 ]; then
echo "✓ Files backup successful"
else
echo "✗ Files backup FAILED"
fi
# 3. Create single archive
echo "Creating compressed archive..."
tar -czf /root/backups/trulern/trulern-full-backup-$DATE.tar.gz -C $BACKUP_DIR .
# 4. Remove raw backup directory (optional)
rm -rf $BACKUP_DIR
# 5. Keep only last 30 days of backups
find /root/backups/trulern -name "*.tar.gz" -mtime +30 -delete
echo "[$(date)] Backup completed"
echo "----------------------------------------"
Make the script executable:
chmod +x /home/username/backup-trulern.sh
Run crontab -e and add this line to back up every day at 3 AM:
0 3 * * * /bin/bash /home/username/backup-trulern.sh >> /var/log/trulern-backup.log 2>&1
This will:
/var/log/trulern-backup.logAlways test that your backups are restorable:
# Extract a backup to test
cd /tmp
tar -xzf /root/backups/trulern/trulern-full-backup-2024-12-31.tar.gz
# Check database dump
ls db/trulern/
# Check files
ls files/
For disaster recovery, copy backups to a different location or cloud storage.
# Install AWS CLI if not present sudo apt install awscli # Configure AWS credentials aws configure # Upload backup to S3 aws s3 cp /root/backups/trulern/trulern-full-backup-2024-12-31.tar.gz s3://your-bucket/backups/
# Install rclone sudo apt install rclone # Configure rclone for Google Drive rclone config # Upload backup rclone copy /root/backups/trulern/trulern-full-backup-2024-12-31.tar.gz gdrive:trulern-backups/
In case of data loss, follow these steps:
# Extract backup cd /tmp tar -xzf /path/to/backup/trulern-full-backup-2024-12-31.tar.gz # Restore MongoDB mongorestore --uri="mongodb+srv://username:password@cluster.mongodb.net/trulern" --drop db/
# Stop the application (optional) pm2 stop trulern-api # Restore files cp -r files/* /var/www/trulern/client/public/uploads/ # Fix permissions sudo chown -R www-data:www-data /var/www/trulern/client/public/uploads/ sudo chmod -R 755 /var/www/trulern/client/public/uploads/ # Restart application pm2 restart trulern-api
Before reaching out for support, please check if you are facing one of these common configuration issues.
Cause: The backend cannot connect to your MongoDB database.
Solution:
mongod) or your Atlas IP whitelist allows
your server's IP..env file:
MONGO_URI=mongodb://localhost:27017/trulern (for local) or your Atlas URI.
@ (use %40 instead).
Cause: Another process is already running on port 5000.
Solution:
npx kill-port 5000PORT variable in your .env file to
5001.
Cause: The JWT_SECRET in your .env file has
changed, invalidating old tokens.
Solution: Clear your browser's Local Storage (Application tab in DevTools) and log in again to generate a new token.
Cause: React frontend is trying to call the backend API from a different domain/port.
Solution:
VITE_API_URL in client/.env points to the correct backend URL:
VITE_API_URL=http://localhost:5000/api
backend/server.js:
app.use(cors({ origin: ['http://localhost:5173', 'https://your-domain.com'] }));
Cause: Missing dependency or incorrect import path.
Solution:
npm install in the client/ directory to ensure all dependencies are installed.node_modules and package-lock.json, then run npm install again.Cause: React environment variables are not properly configured.
Solution:
.env file in the client/ directory (not backend/).VITE_ (e.g., VITE_API_URL)..env file.import.meta.env.VITE_VARIABLE_NAME (not process.env).Cause: Syntax error or unsupported JavaScript feature in your code.
Solution:
npm run build -- --debug for more detailed error output.rm -rf node_modules/.viteCause: React Router configuration issue or missing base path.
Solution:
basename prop if deployed in a subdirectory.index.html for all routes:
location / {
try_files $uri $uri/ /index.html;
}
Cause: API URL is incorrect or Nginx proxy is misconfigured.
Solution:
VITE_API_URL ends with /api or your fetch calls include it:
fetch(`${import.meta.env.VITE_API_URL}/courses`)
curl https://your-domain.com/api/courseslocation /api/ block is correctly set up.If you encounter unexpected behavior, follow these steps to diagnose the issue.
Look at your terminal (or PM2 logs) where the server is running. We have added extensive logging for payments and emails.
# If running with PM2
pm2 logs trulern-api
# If running directly
npm run dev # in backend directory
# Example Success Log
✅ MongoDB connected successfully.
DEBUG: Connecting to: smtp.spacemail.com Port: 465
📧 Password reset email sent to user@example.com
💳 Payment verified for order: 12345
# Example Error Log
❌ Error sending password reset email: Invalid login
❌ Payment verification failed: Transaction not legit!
Open browser DevTools (F12) > Console tab. Look for:
// Helpful console statements you can add
console.log('User data:', user);
console.log('API response:', data);
console.trace('Function called from:'); // Shows call stack
In the Inspector, go to the Network tab:
application/json or multipart/form-data)
// Example: Check if token is being sent
Headers: {
'x-auth-token': 'eyJhbGciOiJIUzI1NiIs...', // Should be present
'Content-Type': 'application/json'
}
Install the React Developer Tools browser extension for advanced debugging:
// Add debug prop to see component renders
const MyComponent = (props) => {
console.log('MyComponent rendered', props);
return <div>...</div>;
};
// Check if context is working
const auth = useAuth();
console.log('Auth context:', auth); // Should contain user, token, etc.
If you're having login/session problems:
// In browser console
// Check if token exists
console.log('Token:', localStorage.getItem('lmsToken'));
// Decode JWT to see expiration
const token = localStorage.getItem('lmsToken');
if (token) {
const payload = JSON.parse(atob(token.split('.')[1]));
console.log('Token payload:', payload);
console.log('Expires:', new Date(payload.exp * 1000));
}
// Check user data
console.log('User:', JSON.parse(localStorage.getItem('lmsUser')));
Use useEffect to track state changes:
useEffect(() => {
console.log('cartItems changed:', cartItems);
}, [cartItems]);
useEffect(() => {
console.log('Component mounted');
return () => console.log('Component unmounted');
}, []);
Verify that your environment variables are loaded correctly:
// In browser console (React)
console.log('API URL:', import.meta.env.VITE_API_URL);
console.log('Stripe Key:', import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY?.substring(0, 10) + '...');
// On server (Node.js)
console.log('NODE_ENV:', process.env.NODE_ENV);
console.log('MONGO_URI:', process.env.MONGO_URI?.substring(0, 20) + '...');
Bypass the frontend and test your API with curl or Postman:
# Test public endpoint
curl http://localhost:5000/api/courses
# Test authenticated endpoint
curl -H "x-auth-token: YOUR_TOKEN" http://localhost:5000/api/user/profile
# Test file upload
curl -X POST -H "x-auth-token: YOUR_TOKEN" \
-F "thumbnail=@image.jpg" \
http://localhost:5000/api/instructor/courses
Open Application tab in DevTools to inspect:
lmsToken, lmsUser, cart_items
// Clear problematic data
localStorage.removeItem('lmsToken');
localStorage.removeItem('lmsUser');
localStorage.removeItem('cart_items');
// Then reload page
When debugging in production:
sudo tail -f /var/log/nginx/error.logpm2 logs trulern-api --lines 100ls -la client/public/uploads/df -hfree -mCommon causes and solutions:
STRIPE_SECRET_KEY in backend/.env starts with sk_test_ (test mode) or sk_live_ (live mode)VITE_STRIPE_PUBLISHABLE_KEY in client/.env starts with pk_test_ or pk_live_pm2 logs trulern-api | grep Stripe
4242 4242 4242 4242 with any future expiry and CVCCommon causes and solutions:
PAYPAL_CLIENT_ID and PAYPAL_CLIENT_SECRET in backend/.env must be correctPAYPAL_MODE=sandbox in backend/.env for testingpm2 logs trulern-api | grep PayPal
sb-abc123@business.example.com / test123Common causes and solutions:
RAZORPAY_KEY_SECRET in backend/.env is correctRAZORPAY_KEY_ID and RAZORPAY_KEY_SECRET in backend/.envVITE_RAZORPAY_KEY_ID in client/.env must match the backend key IDrzp_test_) or both live keys4111 1111 1111 1111 with any future expiry1234 1234 1234 1234 (invalid card)Payment succeeds but user not enrolled:
enrollUser() function in backend/routes/paymentRoutes.jsenrolledCourses arrayDebug Steps for Any Payment Issue:
/api/payment/verify)pm2 logs trulern-api --lines 50
curl -X POST http://localhost:5000/api/payment/verify \
-H "Content-Type: application/json" \
-H "x-auth-token: YOUR_TOKEN" \
-d '{"paymentId":"test","items":[]}'
Environment Variable Checklist:
# backend/.env
STRIPE_SECRET_KEY=sk_test_... # or sk_live_...
PAYPAL_CLIENT_ID=AY... # from developer dashboard
PAYPAL_CLIENT_SECRET=EC... # from developer dashboard
RAZORPAY_KEY_ID=rzp_test_... # or rzp_live_...
RAZORPAY_KEY_SECRET=... # from Razorpay dashboard
# client/.env
VITE_STRIPE_PUBLISHABLE_KEY=pk_test_...
VITE_RAZORPAY_KEY_ID=rzp_test_...
TruLern uses nodemailer for sending announcements and password resets. The configuration is centralized in backend/emailService.js.
DEBUG: Connecting to: smtp.your-host.com Port: 465 User: your@email.com
Configure these in your backend/.env file:
| Environment Variable | Description | Example (Spacemail/Zoho) |
|---|---|---|
EMAIL_HOST |
SMTP Server Address | smtp.spacemail.com |
EMAIL_PORT |
Port Number | 465 (SSL) or 587 (TLS) |
EMAIL_USER |
Your Email Address | support@trulern.com |
EMAIL_PASS |
Email Password | Your secure password or app-specific password |
EMAIL_PORT=465, the system sets secure: true. For all other ports, it uses secure: false. Ensure your host supports the port you choose.
If emails are not arriving:
EAUTH: Invalid login.emailService.js matches your authenticated user.EMAIL_PASS
openssl s_client -connect smtp.gmail.com:465 -crlf -ign_eof
# or using telnet (for port 587)
telnet smtp.gmail.com 587
| Provider | Host | Port (SSL) | Port (TLS) | Notes |
|---|---|---|---|---|
| Gmail | smtp.gmail.com |
465 | 587 | Requires App Password with 2FA |
| Zoho | smtp.zoho.com |
465 | 587 | Use full email address as username |
| SendGrid | smtp.sendgrid.net |
465 | 587 | Username is "apikey", password is your API key |
| Mailgun | smtp.mailgun.org |
465 | 587 | Use your domain's SMTP credentials |
Still stuck? We provide dedicated support for all our customers.
Official support channel via HubSpot
Please open a support ticket for technical assistance, bug reports, or feature requests. Include your purchase code for faster service.
Open Support TicketSupport covers installation issues and bug fixes. It does not cover customization, adding new features, or server management/configuration beyond this documentation.
No. TruLern is a modern MERN Stack application (MongoDB, Express, React, Node.js). It's a standalone full-stack application with a React frontend and Node.js backend. It offers significantly better performance, security, and user experience than WordPress, but requires a Node.js server environment (VPS or specialized hosting).
You can change the branding in two ways:
Replace the logo files in client/public/assets/images/logo/. Keep the same filenames (logo.png, logo-light.png) to avoid changing code.
Change the primary color and other theme variables:
src/assets/scss/_variables.scss:
$primary-color: #2f57ef; /* Change this to your brand color */ $secondary-color: #b12add; /* Change secondary accent */ $heading-color: #393939; /* Heading text color */ $body-color: #525252; /* Paragraph text color */
npm run dev, changes appear instantly. For production, rebuild with npm run build.Yes, but only if your hosting plan includes "Setup Node.js App" in cPanel. Standard PHP hosting will not work because:
We highly recommend a VPS (DigitalOcean, Linode, AWS EC2) or specialized Node.js hosting platforms like:
It depends on what you want to change:
src/assets/scss/.TruLern uses React Router for client-side routing. To add a new page:
src/pages/ (e.g., Webinar.jsx)src/App.jsx:
// Import your component
import Webinar from './pages/Webinar';
// Add route inside the Layout wrapper
<Route element={<Layout />}>
<Route path="/webinar" element={<Webinar />} />
</Route>
/webinarNo backend changes are required as routing is handled entirely in the frontend.
Since this is a file-based delivery, follow these steps to update:
mongodump --uri="YOUR_MONGO_URI" --out=./backupcp -r client/public/uploads ./uploads-backupcp backend/.env backend.env.backup and cp client/.env client.env.backup.env files to the new backend/ and client/ foldersuploads/ folder to client/public/uploads/npm installnpm installcd client && npm run buildpm2 restart trulern-apiEmail logic and HTML templates are centralized in backend/emailService.js. You can modify the HTML strings directly within the functions:
// Password reset email template
function sendPasswordResetEmail(email, userName, resetToken) {
const resetUrl = `${process.env.APP_URL}/reset-password?token=${resetToken}`;
const html = `
<div style="font-family: Arial, sans-serif;">
<h2>Password Reset Request</h2>
<p>Hello ${userName},</p>
<p>Click the button below to reset your password:</p>
<a href="${resetUrl}"
style="background-color: #4CAF50; color: white; padding: 12px 24px;
text-decoration: none; border-radius: 5px;">
Reset Password
</a>
</div>
`;
// Send email...
}
You can customize the HTML, CSS, and text content of these emails. The reset link URL will automatically point to your React frontend's reset password page.
The API URL is controlled by environment variables:
client/.env, set:
VITE_API_URL=https://your-domain.com/api
VITE_API_URL=https://api.your-domain.com
cd client && npm run build
The frontend will use this base URL for all API calls. In development, you can use http://localhost:5000/api.
Dark mode is handled by ModeContext.jsx and controlled by the theme switcher in the header. To customize dark mode colors:
src/assets/scss/_dark-mode.scss
.dark-mode {
--bg-color: #1a1a1a;
--text-color: #f0f0f0;
--primary-color: #6c5ce7;
// ... other variables
}
To ensure your LMS runs smoothly and scales effectively, follow these recommendations.
Always compress images before uploading or adding them to the assets folder. Use WebP format where possible for faster loading times.
Always use PM2 in production. Never run npm start
or node server.js directly for a live site, as it won't restart on crashes or
reboots.
Schedule a cron job to backup your MongoDB database and the uploads/
folder. Data loss is irreversible without backups.
Use Git to track your changes. If you modify core files, having a git history makes it much easier to merge future updates from us.
Your .env file contains sensitive keys. Ensure JWT_SECRET is a long,
random string (e.g., 64 characters) unrelated to the demo data.
Never run an LMS dealing with payments and passwords over HTTP. Use Nginx with a free Let's Encrypt SSL certificate to encrypt all traffic.
Ensure NODE_ENV=production is set in your environment variables. This disables stack
trace leakage to the client and enables performance optimizations in Express.
If using MongoDB Atlas, whitelist only your VPS IP address. If using local MongoDB, ensure port 27017 is blocked from outside access using a firewall (UFW).
TruLern LMS uses these open-source libraries and assets. We're grateful to their creators.
Demo assets (Flaticon, Freepik, Pexels, Unsplash) are for preview only. These assets are not included in the download package. Buyers must replace them with their own properly licensed images and icons for production use.
Copyright © 2026 TruLern. All Rights Reserved