Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions app.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import payoutRoutes from "./src/routes/payoutRoutes.js";
import uploadRoutes from "./src/routes/uploadRoutes.js";
import jobsRoutes from "./src/routes/jobsRoutes.js";
import wellKnownRoutes from "./src/routes/wellKnownRoutes.js";
import analyticsRoutes from "./src/routes/analytics/analyticsRoutes.js";

handleUncaughtException();
validateEnv();
Expand Down Expand Up @@ -187,6 +188,7 @@ app.use("/api/stellar/payment", stellarPaymentRoutes);
app.use("/api/stellar/donation", stellarDonationRoutes);
app.use("/api/payouts", payoutRoutes);
app.use("/api/uploads", uploadRoutes);
app.use("/api/analytics", analyticsRoutes);
app.use("/admin/jobs", jobsRoutes);

// ======================
Expand Down
278 changes: 278 additions & 0 deletions src/controllers/analytics/analyticsController.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,278 @@
import mongoose from "mongoose";
import Transaction from "../../models/Transaction.js";
import CourseProgress from "../../models/CourseProgress.js";
import Course from "../../models/Course.js";
import User from "../../models/User.js";
import logger from "../../config/logger.js";
Comment on lines +1 to +6

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Missing required "buyer spending" analytics endpoint.

Issue #61 explicitly lists platform volume, time series, top educators, top items, per-educator earnings, and buyer spending as required analytics. This file implements all of those except buyer spending — there's no endpoint aggregating spend per buyer.

Would you like me to draft a getBuyerSpending/getMyPurchases aggregation (grouping confirmed Transactions by buyer, summing $toDecimal amounts) and the corresponding route?

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/controllers/analytics/analyticsController.js` around lines 1 - 6,
Implement the missing buyer-spending analytics by adding a getBuyerSpending or
getMyPurchases controller that aggregates confirmed Transaction records by buyer
and sums amounts using $toDecimal, then expose it through the corresponding
analytics route. Follow the existing controller and route patterns for
authentication, response shape, and error handling.


const toDecimalString = (value) => {
if (value === null || value === undefined || value === "") return "0";
if (typeof value === "string") return value;
return value.toString();
};

const buildDateFilter = ({ from, to }) => {
const filter = {};
if (from || to) {
filter.confirmedAt = {};
if (from) filter.confirmedAt.$gte = new Date(from);
if (to) filter.confirmedAt.$lte = new Date(to);
}
return filter;
};
Comment on lines +14 to +22

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Validate from/to before building the date filter.

new Date(from)/new Date(to) on a malformed query string silently produces Invalid Date, which then flows into $gte/$lte on confirmedAt and can throw a cast error inside the aggregation (surfacing as an opaque 500) instead of a clear 400. This affects every caller of buildDateFilter (educator earnings and platform analytics).

🛡️ Proposed validation
 const buildDateFilter = ({ from, to }) => {
   const filter = {};
   if (from || to) {
     filter.confirmedAt = {};
-    if (from) filter.confirmedAt.$gte = new Date(from);
-    if (to) filter.confirmedAt.$lte = new Date(to);
+    if (from) {
+      const fromDate = new Date(from);
+      if (Number.isNaN(fromDate.getTime())) throw new Error("Invalid 'from' date");
+      filter.confirmedAt.$gte = fromDate;
+    }
+    if (to) {
+      const toDate = new Date(to);
+      if (Number.isNaN(toDate.getTime())) throw new Error("Invalid 'to' date");
+      filter.confirmedAt.$lte = toDate;
+    }
   }
   return filter;
 };
As per path instructions for `src/**/*.js`: "Flag missing auth/ownership checks, unvalidated request input, and unhandled promise rejections."
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const buildDateFilter = ({ from, to }) => {
const filter = {};
if (from || to) {
filter.confirmedAt = {};
if (from) filter.confirmedAt.$gte = new Date(from);
if (to) filter.confirmedAt.$lte = new Date(to);
}
return filter;
};
const buildDateFilter = ({ from, to }) => {
const filter = {};
if (from || to) {
filter.confirmedAt = {};
if (from) {
const fromDate = new Date(from);
if (Number.isNaN(fromDate.getTime())) throw new Error("Invalid 'from' date");
filter.confirmedAt.$gte = fromDate;
}
if (to) {
const toDate = new Date(to);
if (Number.isNaN(toDate.getTime())) throw new Error("Invalid 'to' date");
filter.confirmedAt.$lte = toDate;
}
}
return filter;
};
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/controllers/analytics/analyticsController.js` around lines 14 - 22,
Update buildDateFilter to validate each provided from and to value before
assigning it to confirmedAt.$gte or confirmedAt.$lte. Reject malformed dates
through the controller’s established client-error path so callers receive a
clear 400 response, while preserving the existing filter behavior for valid or
omitted values across educator earnings and platform analytics.

Source: Path instructions


export const getEducatorEarnings = async (req, res) => {
try {
const educatorId = req.user._id;
const { from, to, limit = 10 } = req.query;

const match = {
creator: educatorId,
status: "confirmed",
...buildDateFilter({ from, to }),
};
Comment on lines +24 to +33

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Educator earnings ignores currency/network filters.

The PR objectives state shared filters across the analytics endpoints include date range, interval, currency, network, and limit — getPlatformAnalytics honors currency/network (lines 92-93) but getEducatorEarnings only supports from/to/limit. An educator can't scope their own earnings by network/currency the same way admins can for platform data.

🔧 Proposed fix
   const educatorId = req.user._id;
-  const { from, to, limit = 10 } = req.query;
+  const { from, to, limit = 10, currency, network } = req.query;

   const match = {
     creator: educatorId,
     status: "confirmed",
     ...buildDateFilter({ from, to }),
   };
+  if (currency) match.currency = currency;
+  if (network) match.network = network;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export const getEducatorEarnings = async (req, res) => {
try {
const educatorId = req.user._id;
const { from, to, limit = 10 } = req.query;
const match = {
creator: educatorId,
status: "confirmed",
...buildDateFilter({ from, to }),
};
export const getEducatorEarnings = async (req, res) => {
try {
const educatorId = req.user._id;
const { from, to, limit = 10, currency, network } = req.query;
const match = {
creator: educatorId,
status: "confirmed",
...buildDateFilter({ from, to }),
};
if (currency) match.currency = currency;
if (network) match.network = network;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/controllers/analytics/analyticsController.js` around lines 24 - 33,
Update getEducatorEarnings to read currency and network from req.query and apply
both as filters in its match criteria, using the same filter field names and
handling conventions as getPlatformAnalytics. Preserve the existing educator,
confirmed-status, date-range, and limit behavior.


const [summary, items] = await Promise.all([
Transaction.aggregate([
{ $match: match },
{
$group: {
_id: null,
totalVolume: { $sum: { $toDecimal: "$amount" } },
transactionCount: { $sum: 1 },
uniqueBuyers: { $addToSet: "$buyer" },
},
},
{
$project: {
_id: 0,
totalVolume: { $toString: "$totalVolume" },
transactionCount: 1,
uniqueBuyerCount: { $size: "$uniqueBuyers" },
},
},
]),
Transaction.aggregate([
{ $match: match },
{ $group: { _id: "$itemId", title: { $first: "$itemTitle" }, totalVolume: { $sum: { $toDecimal: "$amount" } }, sales: { $sum: 1 } } },
{ $sort: { totalVolume: -1, sales: -1 } },
{ $limit: parseInt(limit, 10) || 10 },
{ $project: { _id: 0, itemId: "$_id", title: 1, totalVolume: { $toString: "$totalVolume" }, sales: 1 } },
]),
]);

const summaryData = summary[0] || { totalVolume: "0", transactionCount: 0, uniqueBuyerCount: 0 };

res.status(200).json({
success: true,
summary: {
totalVolume: toDecimalString(summaryData.totalVolume),
transactionCount: summaryData.transactionCount || 0,
uniqueBuyers: summaryData.uniqueBuyerCount || 0,
},
items,
});
} catch (error) {
logger.error("Analytics educator earnings error:", error);
res.status(500).json({ success: false, message: "Failed to fetch educator earnings" });
}
};

export const getPlatformAnalytics = async (req, res) => {
try {
const { from, to, interval = "day", currency, network, limit = 10 } = req.query;
if (req.user.role !== "admin") {
return res.status(403).json({ success: false, message: "Only admins can view platform analytics" });
}

const match = {
status: "confirmed",
...buildDateFilter({ from, to }),
};
if (currency) match.currency = currency;
if (network) match.network = network;
Comment on lines +88 to +93

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect Transaction model for indexes supporting analytics queries
fd -i transaction.js src/models
cat -n src/models/Transaction.js 2>/dev/null | sed -n '1,200p'
rg -n 'index\(|index:\s*true' -g '*Transaction.js'

Repository: Deen-Bridge/dnb-backend

Length of output: 4951


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the analytics controller query patterns around the reported lines
cat -n src/controllers/analytics/analyticsController.js | sed -n '1,220p'

# Look for any additional Transaction indexes or analytics-specific query builders
rg -n "confirmedAt|buildDateFilter|status: \"confirmed\"|creator|currency|network|index\\(" src/controllers src/models

Repository: Deen-Bridge/dnb-backend

Length of output: 20524


Add a compound index for the analytics match patterns src/models/Transaction.js only has indexes on buyer/status, creator/status, itemType/itemId, and type/status/createdAt; these analytics queries filter on creator/status/confirmedAt and status/confirmedAt (plus optional currency/network), so add an index that includes confirmedAt for these read paths.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/controllers/analytics/analyticsController.js` around lines 88 - 93, Add
compound indexes in the Transaction model to cover analytics queries filtering
by status, confirmedAt, and optionally currency/network, while also covering the
creator/status/confirmedAt pattern used by the analytics match. Preserve all
existing indexes and define the new indexes alongside them in the model schema.


const [summary, timeseries, topEducators, topItems] = await Promise.all([
Transaction.aggregate([
{ $match: match },
{
$group: {
_id: null,
totalVolume: { $sum: { $toDecimal: "$amount" } },
transactionCount: { $sum: 1 },
uniqueBuyers: { $addToSet: "$buyer" },
currencies: { $addToSet: "$currency" },
networks: { $addToSet: "$network" },
},
},
{
$project: {
_id: 0,
totalVolume: { $toString: "$totalVolume" },
transactionCount: 1,
uniqueBuyerCount: { $size: "$uniqueBuyers" },
currencies: 1,
networks: 1,
},
},
]),
Transaction.aggregate([
{ $match: match },
{
$group: {
_id: {
day: { $dateTrunc: { date: "$confirmedAt", unit: interval === "month" ? "month" : interval === "week" ? "week" : "day" } },
},
volume: { $sum: { $toDecimal: "$amount" } },
count: { $sum: 1 },
},
},
{ $sort: { _id: 1 } },
{ $project: { _id: 0, bucket: "$_id.day", volume: { $toString: "$volume" }, count: 1 } },
]),
Transaction.aggregate([
{ $match: match },
{ $group: { _id: "$creator", totalVolume: { $sum: { $toDecimal: "$amount" } }, sales: { $sum: 1 } } },
{ $sort: { totalVolume: -1, sales: -1 } },
{ $limit: parseInt(limit, 10) || 10 },
{ $lookup: { from: "users", localField: "_id", foreignField: "_id", as: "creatorProfile" } },
{ $unwind: { path: "$creatorProfile", preserveNullAndEmptyArrays: true } },
{ $project: { _id: 0, creatorId: "$_id", name: "$creatorProfile.name", avatar: "$creatorProfile.avatar", totalVolume: { $toString: "$totalVolume" }, sales: 1 } },
]),
Transaction.aggregate([
{ $match: match },
{ $group: { _id: "$itemId", title: { $first: "$itemTitle" }, totalVolume: { $sum: { $toDecimal: "$amount" } }, sales: { $sum: 1 } } },
{ $sort: { totalVolume: -1, sales: -1 } },
{ $limit: parseInt(limit, 10) || 10 },
{ $project: { _id: 0, itemId: "$_id", title: 1, totalVolume: { $toString: "$totalVolume" }, sales: 1 } },
]),
]);

const summaryData = summary[0] || { totalVolume: "0", transactionCount: 0, uniqueBuyerCount: 0, currencies: [], networks: [] };

res.status(200).json({
success: true,
summary: {
totalVolume: toDecimalString(summaryData.totalVolume),
transactionCount: summaryData.transactionCount || 0,
uniqueBuyers: summaryData.uniqueBuyerCount || 0,
currencies: summaryData.currencies || [],
networks: summaryData.networks || [],
},
timeseries,
topEducators: topEducators || [],
topItems: topItems || [],
});
} catch (error) {
logger.error("Platform analytics error:", error);
res.status(500).json({ success: false, message: "Failed to fetch platform analytics" });
}
};

export const getCourseProgress = async (req, res) => {
try {
const courseId = req.params.id;
const userId = req.user._id;
const course = await Course.findById(courseId);
if (!course) return res.status(404).json({ success: false, message: "Course not found" });

const isOwner = course.createdBy && course.createdBy.toString() === userId.toString();
const isEnrolled = course.enrolledUsers?.some((entry) => entry.toString() === userId.toString());
if (!isOwner && !isEnrolled) {
return res.status(403).json({ success: false, message: "You do not have access to this course" });
}

let progress = await CourseProgress.findOne({ user: userId, course: courseId });
if (!progress) {
const lessonCount = (course.sections || []).reduce((count, section) => count + (section.lessons || []).length, 0) || 1;
progress = await CourseProgress.create({
user: userId,
course: courseId,
lessonsCompleted: [],
lastLesson: null,
lastPositionSeconds: 0,
percentComplete: 0,
});
progress = progress.toObject();
}

res.status(200).json({ success: true, progress });
} catch (error) {
logger.error("Get course progress error:", error);
res.status(500).json({ success: false, message: "Failed to fetch course progress" });
}
};

export const updateCourseProgress = async (req, res) => {
try {
const courseId = req.params.id;
const userId = req.user._id;
const { lessonId, lastPositionSeconds } = req.body;
const course = await Course.findById(courseId);
if (!course) return res.status(404).json({ success: false, message: "Course not found" });

const isOwner = course.createdBy && course.createdBy.toString() === userId.toString();
const isEnrolled = course.enrolledUsers?.some((entry) => entry.toString() === userId.toString());
if (!isOwner && !isEnrolled) {
return res.status(403).json({ success: false, message: "You do not have access to this course" });
}

const lessonCount = (course.sections || []).reduce((count, section) => count + (section.lessons || []).length, 0) || 1;

let progress = await CourseProgress.findOne({ user: userId, course: courseId });
if (!progress) {
progress = await CourseProgress.create({
user: userId,
course: courseId,
lessonsCompleted: [],
lastLesson: null,
lastPositionSeconds: 0,
percentComplete: 0,
});
}

if (lessonId) {
const lessonObjectId = new mongoose.Types.ObjectId(lessonId);
progress.lessonsCompleted = Array.from(new Set([...(progress.lessonsCompleted || []), lessonObjectId]));
}

progress.lastLesson = lessonId ? new mongoose.Types.ObjectId(lessonId) : progress.lastLesson;
progress.lastPositionSeconds = lastPositionSeconds ?? progress.lastPositionSeconds;
const percent = Math.min(100, Math.round((progress.lessonsCompleted.length / lessonCount) * 100));
progress.percentComplete = percent;
if (percent >= 100) progress.completedAt = progress.completedAt || new Date();
await progress.save();

res.status(200).json({ success: true, progress });
} catch (error) {
logger.error("Update course progress error:", error);
res.status(500).json({ success: false, message: "Failed to update course progress" });
}
};
Comment on lines +172 to +251

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Race condition creating CourseProgress under concurrent requests.

Both getCourseProgress and updateCourseProgress do a findOne followed by a conditional create (lines 185-197, 222-232). If two requests for the same (user, course) pair race (e.g., a user opening the same course in two tabs), both can pass the !progress check before either insert completes; the second create will throw a duplicate-key error against the unique {user,course} index and bubble up as a generic 500 instead of succeeding.

Use an atomic upsert to remove the TOCTOU window:

🔒 Proposed fix (apply to both functions)
-    let progress = await CourseProgress.findOne({ user: userId, course: courseId });
-    if (!progress) {
-      progress = await CourseProgress.create({
-        user: userId,
-        course: courseId,
-        lessonsCompleted: [],
-        lastLesson: null,
-        lastPositionSeconds: 0,
-        percentComplete: 0,
-      });
-    }
+    const progress = await CourseProgress.findOneAndUpdate(
+      { user: userId, course: courseId },
+      { $setOnInsert: { lessonsCompleted: [], lastLesson: null, lastPositionSeconds: 0, percentComplete: 0 } },
+      { upsert: true, new: true }
+    );
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/controllers/analytics/analyticsController.js` around lines 172 - 251,
Replace the findOne-then-conditional-create initialization in both
getCourseProgress and updateCourseProgress with an atomic upsert using the
existing { user, course } key. Preserve the current default progress fields and
ensure concurrent requests reuse the existing document rather than returning a
duplicate-key 500; retain the subsequent update and response behavior.


export const getUserLearning = async (req, res) => {
try {
const userId = req.user._id;
const { status = "in-progress" } = req.query;
const progressRecords = await CourseProgress.find({ user: userId }).sort({ updatedAt: -1 }).populate("course");
const courses = progressRecords
.map((record) => ({
_id: record.course?._id,
title: record.course?.title || "Course",
percentComplete: record.percentComplete || 0,
lastLesson: record.lastLesson,
lastPositionSeconds: record.lastPositionSeconds || 0,
completedAt: record.completedAt,
updatedAt: record.updatedAt,
}))
.filter((entry) => {
if (status === "completed") return entry.percentComplete >= 100;
return entry.percentComplete < 100;
});

res.status(200).json({ success: true, courses });
} catch (error) {
logger.error("Get user learning error:", error);
res.status(500).json({ success: false, message: "Failed to fetch learning dashboard" });
}
};
Comment on lines +253 to +278

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

Duplicates userController.js#getLearningDashboard, and fetches more data than needed.

This handler and getLearningDashboard in src/controllers/userController.js do essentially the same query/filter/map over CourseProgress, exposed on two different routes (/api/analytics/users/me/learning vs /api/users/me/learning). Unlike the userController.js version, this one doesn't use .lean() or restrict populate("course") to specific fields, so it fetches the entire Course document (description, price, reviews, enrolledUsers, etc.) per progress record just to discard most of it in the .map(). See the consolidated comment for the full cross-file picture.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/controllers/analytics/analyticsController.js` around lines 253 - 278,
Remove the duplicate getUserLearning implementation and its route usage, reusing
userController.js#getLearningDashboard for the learning dashboard endpoint
instead. Ensure the consolidated handler retains the required status filtering
and response shape while using lean queries and selecting only the course fields
needed by the mapping.

6 changes: 4 additions & 2 deletions src/controllers/courses/courseController.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { createNewCourseNotification } from "../notificationController.js";
* Backend receives URLs instead of file buffers
*/
export const createCourse = catchAsync(async (req, res, next) => {
const { title, description, category, price, thumbnail, video } = req.body;
const { title, description, category, price, thumbnail, video, sections } = req.body;

logger.info(`Creating course: ${title} by user: ${req.user._id}`);

Expand All @@ -31,6 +31,7 @@ export const createCourse = catchAsync(async (req, res, next) => {
createdBy: req.user._id,
thumbnail: thumbnail || null, // URL from frontend
video: video || null, // URL from frontend
...(Array.isArray(sections) && sections.length > 0 ? { sections } : {}),
});

logger.info(`✅ Course created successfully: ${course._id} - ${title}`);
Expand Down Expand Up @@ -164,7 +165,7 @@ export const enrollInCourse = async (req, res) => {

// 📝 Edit/Update a course
export const updateCourse = catchAsync(async (req, res, next) => {
const { title, description, category, price, thumbnail, video } = req.body;
const { title, description, category, price, thumbnail, video, sections } = req.body;
const courseId = req.params.id;

logger.info(`Updating course: ${courseId}`);
Expand All @@ -191,6 +192,7 @@ export const updateCourse = catchAsync(async (req, res, next) => {
// Update media URLs if provided
if (thumbnail) course.thumbnail = thumbnail;
if (video) course.video = video;
if (Array.isArray(sections)) course.sections = sections;

await course.save();

Expand Down
34 changes: 34 additions & 0 deletions src/controllers/userController.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import Course from "../models/Course.js";
import Book from "../models/Book.js";
import logger from "../config/logger.js";
import { validateMagicBytes } from "../utils/fileValidation.js";
import CourseProgress from "../models/CourseProgress.js";
import { createFollowNotification, createUnfollowNotification } from "./notificationController.js";

// Update user profile (including avatar upload to Cloudinary)
Expand Down Expand Up @@ -530,6 +531,39 @@ export const getRecommendations = async (req, res) => {
}
};

export const getLearningDashboard = async (req, res) => {
try {
const userId = req.user._id;
const { status = "in-progress" } = req.query;

const progressRecords = await CourseProgress.find({ user: userId })
.sort({ updatedAt: -1 })
.populate("course", "title thumbnail createdBy")
.lean();

const courses = progressRecords
.filter((record) => {
if (status === "completed") return record.percentComplete >= 100;
return record.percentComplete < 100;
})
.map((record) => ({
_id: record.course?._id,
title: record.course?.title || "Course",
thumbnail: record.course?.thumbnail || null,
percentComplete: record.percentComplete || 0,
lastLesson: record.lastLesson,
lastPositionSeconds: record.lastPositionSeconds || 0,
completedAt: record.completedAt,
updatedAt: record.updatedAt,
}));

res.status(200).json({ success: true, courses });
} catch (error) {
logger.error("Get learning dashboard error:", error);
res.status(500).json({ success: false, message: "Failed to fetch learning dashboard" });
}
};

// Get user statistics
export const getUserStats = async (req, res) => {
try {
Expand Down
Loading
Loading