-
Notifications
You must be signed in to change notification settings - Fork 23
Add revenue analytics endpoints for educators and platform reporting #80
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Validate
🛡️ 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;
};📝 Committable suggestion
Suggested change
🤖 Prompt for AI AgentsSource: 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Educator earnings ignores The PR objectives state shared filters across the analytics endpoints include date range, interval, currency, network, and limit — 🔧 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
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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/modelsRepository: Deen-Bridge/dnb-backend Length of output: 20524 Add a compound index for the analytics match patterns 🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win Race condition creating Both 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 |
||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win Duplicates This handler and 🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
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
#61explicitly 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/getMyPurchasesaggregation (grouping confirmedTransactions bybuyer, summing$toDecimalamounts) and the corresponding route?🤖 Prompt for AI Agents