Skip to content

Add revenue analytics endpoints for educators and platform reporting - #80

Open
Myart352 wants to merge 1 commit into
Deen-Bridge:devfrom
Myart352:#61-analytics-revenue-reporting
Open

Add revenue analytics endpoints for educators and platform reporting#80
Myart352 wants to merge 1 commit into
Deen-Bridge:devfrom
Myart352:#61-analytics-revenue-reporting

Conversation

@Myart352

@Myart352 Myart352 commented Jul 26, 2026

Copy link
Copy Markdown

closes #61

This PR adds backend analytics support for educator earnings and platform-level reporting so the application can expose revenue insights for courses and creators.

What this change adds:

  • New analytics endpoints for educator earnings and platform-wide reporting
  • Aggregation logic for confirmed payments and revenue summaries
  • Support for structured analytics responses that can be consumed by the frontend
  • Regression tests covering the new analytics behavior

Files changed:

  • dnb-backend_MDSMITH/app.js
  • dnb-backend_MDSMITH/src/controllers/analytics/analyticsController.js
  • dnb-backend_MDSMITH/src/routes/analytics/analyticsRoutes.js
  • dnb-backend_MDSMITH/src/models/Course.js
  • dnb-backend_MDSMITH/src/controllers/courses/courseController.js
  • dnb-backend_MDSMITH/src/controllers/userController.js
  • dnb-backend_MDSMITH/src/routes/courses/courseRoutes.js
  • dnb-backend_MDSMITH/src/routes/userRoutes.js
  • dnb-backend_MDSMITH/test/analytics.test.js

What we changed:

  • Registered new analytics routes in the application entrypoint
  • Implemented controller logic to compute educator earnings and platform analytics
  • Added supporting model and route wiring for the analytics workflows
  • Added regression coverage to validate the new reporting behavior

Summary by CodeRabbit

  • New Features
    • Added educator earnings and admin platform analytics.
    • Added course progress tracking, including completion status and playback position.
    • Added a learning dashboard for viewing in-progress and completed courses.
    • Added support for course sections, lessons, previews, durations, and resources.
    • Added authenticated endpoints for analytics, course progress, and learning activity.
  • Bug Fixes
    • Improved access control for analytics and course progress data.
  • Tests
    • Added coverage for earnings calculations, transaction summaries, and admin authorization.

@coderabbitai

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

Adds transaction analytics APIs, course sections, course progress tracking, and learning dashboard endpoints. Protected routes and tests cover analytics access control, decimal totals, progress updates, and learning data retrieval.

Changes

Analytics and Learning Progress

Layer / File(s) Summary
Course and progress data contracts
src/models/Course.js, src/models/CourseProgress.js, src/controllers/courses/courseController.js
Courses now support sections and lessons, while CourseProgress stores per-user completion state. Course creation and updates accept section data.
Analytics and progress handlers
src/controllers/analytics/analyticsController.js, src/controllers/userController.js
Adds educator and platform transaction aggregations, course progress read/update handlers, and filtered learning dashboard responses.
API wiring and analytics validation
src/routes/analytics/analyticsRoutes.js, src/routes/courses/courseRoutes.js, src/routes/userRoutes.js, app.js, test/analytics.test.js
Mounts protected analytics, progress, and learning routes, with tests for precise earnings totals and admin-only platform analytics.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant analyticsRoutes
  participant analyticsController
  participant MongoDB
  Client->>analyticsRoutes: Request protected analytics or learning endpoint
  analyticsRoutes->>analyticsController: Apply authorization and invoke handler
  analyticsController->>MongoDB: Aggregate transactions or query progress
  MongoDB-->>analyticsController: Return analytics or progress data
  analyticsController-->>Client: Return JSON response
Loading

Possibly related PRs

Suggested reviewers: zeemscript, kaycee276

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The PR covers some revenue analytics, but it omits key #61 requirements like time-series, buyer-spend, and explicit confirmed-only/$toDecimal reporting. Add the missing analytics endpoints and confirmed-only decimal aggregation logic, and remove unrelated course-progress work from this issue.
Out of Scope Changes check ⚠️ Warning Course progress, learning-dashboard, and course-section schema changes are unrelated to the revenue analytics scope in #61. Split the course-progress and dashboard changes into a separate PR unless they belong to another linked issue.
✅ Passed checks (3 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the new revenue analytics endpoints, which are a major part of this PR.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 9

🧹 Nitpick comments (4)
src/routes/analytics/analyticsRoutes.js (1)

1-1: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Course progress endpoints are mounted twice, under two different domains, for the exact same handlers. getCourseProgress/updateCourseProgress are wired at both /api/analytics/courses/:id/progress and /api/courses/:id/progress. This doubles the API surface to document/support for no behavioral benefit and invites drift if one copy is later updated (e.g., different middleware) without the other.

  • src/routes/analytics/analyticsRoutes.js#L18-19: drop these two routes — course progress belongs under the courses domain.
  • src/routes/courses/courseRoutes.js#L56-66: keep this as the single source of truth for /:id/progress (GET/POST).
🤖 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/routes/analytics/analyticsRoutes.js` at line 1, Remove the duplicate
course progress route registrations from the analytics routes, including the GET
and POST handlers for getCourseProgress and updateCourseProgress. Keep the
corresponding /:id/progress routes in courseRoutes.js as the sole source of
truth.
src/controllers/analytics/analyticsController.js (1)

36-61: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicated aggregation logic across educator/platform reports.

The "totalVolume/transactionCount/uniqueBuyers" summary shape (lines 39-53 vs 99-117) and the "top items" pipeline (lines 57-60 vs 143-147) are copy-pasted with only minor field differences. Extracting a small helper (e.g., buildSummaryPipeline(match, extraGroupFields) and buildTopItemsPipeline(match, limit)) would reduce the risk of the two copies drifting (e.g., if $toDecimal handling needs to change later, it'd need updating in 4 places today).

Also applies to: 95-149

🤖 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 36 - 61,
Extract the duplicated aggregation stages in the analytics controller into
shared helpers, such as buildSummaryPipeline(match, extraGroupFields) for the
totalVolume/transactionCount/uniqueBuyers summary and
buildTopItemsPipeline(match, limit) for the top-items results. Update both
educator and platform report paths to use these helpers while preserving their
existing field-specific grouping and output shapes.
test/analytics.test.js (1)

10-166: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Missing coverage for several new endpoints.

This suite only exercises /me/earnings and /platform. The following new routes have no automated coverage: /platform/timeseries, /top-educators, /top-items, GET/POST /courses/:id/progress (or /analytics/courses/:id/progress), and the two learning-dashboard routes. Given these are new, security-sensitive (auth/role-gated) endpoints, please add at least smoke tests asserting status codes and access control for each.

As per path instructions for src/**/*.js: "New or changed endpoints need Jest coverage."

🤖 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 `@test/analytics.test.js` around lines 10 - 166, Extend the “Revenue analytics
endpoints” Jest suite with smoke tests for /platform/timeseries, /top-educators,
/top-items, both course-progress methods, and both learning-dashboard routes.
For each endpoint, assert successful access for the appropriate authenticated
role and rejection for unauthorized or disallowed roles, reusing the existing
educatorToken, adminToken, studentToken, and test setup.

Source: Path instructions

src/routes/courses/courseRoutes.js (1)

12-15: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Wiring itself is correct; see consolidated comment for route duplication with analyticsRoutes.js.

Also applies to: 56-66

🤖 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/routes/courses/courseRoutes.js` around lines 12 - 15, Remove the
duplicated course-progress route wiring between courseRoutes.js and
analyticsRoutes.js, including the handlers imported from getCourseProgress and
updateCourseProgress. Keep these endpoints registered in only one canonical
route module and preserve their existing behavior.
🤖 Prompt for all review comments with 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.

Inline comments:
In `@src/controllers/analytics/analyticsController.js`:
- Around line 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.
- Around line 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.
- Around line 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.
- Around line 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.
- Around line 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.
- Around line 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.

In `@src/routes/analytics/analyticsRoutes.js`:
- Line 20: The learning dashboard is duplicated across analytics and user
routes. Remove the analytics route using getUserLearning and its redundant
controller implementation, while keeping getLearningDashboard in
userController.js as the canonical implementation and preserving the sole
/me/learning endpoint in src/routes/userRoutes.js at line 129; no direct change
is needed there.
- Around line 13-17: Update the analytics route definitions using
getPlatformAnalytics so each endpoint selects a dedicated report/view for its
advertised data: platform, timeseries, top-educators, and top-items. Modify
getPlatformAnalytics to honor that selector and run/return only the
corresponding aggregation, while preserving protect and admin authorization.

In `@test/analytics.test.js`:
- Around line 42-44: Remove the hardcoded fallback from the token setup around
educatorToken, adminToken, and studentToken. Validate that
process.env.JWT_SECRET is present before signing tokens, failing fast with a
clear configuration error when it is missing, and pass the validated environment
value to jwt.sign for all test tokens.

---

Nitpick comments:
In `@src/controllers/analytics/analyticsController.js`:
- Around line 36-61: Extract the duplicated aggregation stages in the analytics
controller into shared helpers, such as buildSummaryPipeline(match,
extraGroupFields) for the totalVolume/transactionCount/uniqueBuyers summary and
buildTopItemsPipeline(match, limit) for the top-items results. Update both
educator and platform report paths to use these helpers while preserving their
existing field-specific grouping and output shapes.

In `@src/routes/analytics/analyticsRoutes.js`:
- Line 1: Remove the duplicate course progress route registrations from the
analytics routes, including the GET and POST handlers for getCourseProgress and
updateCourseProgress. Keep the corresponding /:id/progress routes in
courseRoutes.js as the sole source of truth.

In `@src/routes/courses/courseRoutes.js`:
- Around line 12-15: Remove the duplicated course-progress route wiring between
courseRoutes.js and analyticsRoutes.js, including the handlers imported from
getCourseProgress and updateCourseProgress. Keep these endpoints registered in
only one canonical route module and preserve their existing behavior.

In `@test/analytics.test.js`:
- Around line 10-166: Extend the “Revenue analytics endpoints” Jest suite with
smoke tests for /platform/timeseries, /top-educators, /top-items, both
course-progress methods, and both learning-dashboard routes. For each endpoint,
assert successful access for the appropriate authenticated role and rejection
for unauthorized or disallowed roles, reusing the existing educatorToken,
adminToken, studentToken, and test setup.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 99bb07bb-5546-4d56-a7b0-7fc2c34b390a

📥 Commits

Reviewing files that changed from the base of the PR and between 3ce98b8 and f8eecfc.

📒 Files selected for processing (10)
  • app.js
  • src/controllers/analytics/analyticsController.js
  • src/controllers/courses/courseController.js
  • src/controllers/userController.js
  • src/models/Course.js
  • src/models/CourseProgress.js
  • src/routes/analytics/analyticsRoutes.js
  • src/routes/courses/courseRoutes.js
  • src/routes/userRoutes.js
  • test/analytics.test.js

Comment on lines +1 to +6
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";

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.

Comment on lines +14 to +22
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;
};

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

Comment on lines +24 to +33
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 }),
};

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.

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

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.

Comment on lines +172 to +251
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" });
}
};

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.

Comment on lines +253 to +278
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" });
}
};

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.

Comment on lines +13 to +17
router.get("/me/earnings", protect, getEducatorEarnings);
router.get("/platform", protect, authorizeRoles("admin"), getPlatformAnalytics);
router.get("/platform/timeseries", protect, authorizeRoles("admin"), getPlatformAnalytics);
router.get("/top-educators", protect, authorizeRoles("admin"), getPlatformAnalytics);
router.get("/top-items", protect, authorizeRoles("admin"), getPlatformAnalytics);

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== analyticsRoutes.js outline =="
ast-grep outline src/routes/analytics/analyticsRoutes.js --view expanded || true

echo
echo "== analytics controller outline =="
fd -a 'analytics' src | head -20
fd -a 'analyticsController' src | head -20
fd -a 'analytics*.js' src | head -20

Repository: Deen-Bridge/dnb-backend

Length of output: 582


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== analyticsRoutes.js (relevant lines) =="
cat -n src/routes/analytics/analyticsRoutes.js | sed -n '1,120p'

echo
echo "== controller files mentioning getPlatformAnalytics =="
rg -n "getPlatformAnalytics|platform/timeseries|top-educators|top-items|platform" src/routes src/controllers src -g '*.js' -g '*.mjs' || true

Repository: Deen-Bridge/dnb-backend

Length of output: 11047


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Read only the relevant files in small slices.
echo "== src/routes/analytics/analyticsRoutes.js =="
cat -n src/routes/analytics/analyticsRoutes.js | sed -n '1,120p'

echo
echo "== files that define getPlatformAnalytics or related routes =="
rg -n "getPlatformAnalytics|/platform/timeseries|/top-educators|/top-items|/me/earnings" src -g '*.js' || true

Repository: Deen-Bridge/dnb-backend

Length of output: 2158


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Print only the route definitions around the analytics endpoints.
sed -n '1,80p' src/routes/analytics/analyticsRoutes.js

echo
echo "---"
# Print the controller implementation if present.
fd -a 'analyticsController.js' src || true

Repository: Deen-Bridge/dnb-backend

Length of output: 1177


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
p = Path('src/routes/analytics/analyticsRoutes.js')
print(p.read_text())
PY

Repository: Deen-Bridge/dnb-backend

Length of output: 1105


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== controller file size =="
wc -l src/controllers/analytics/analyticsController.js

echo
echo "== controller outline =="
ast-grep outline src/controllers/analytics/analyticsController.js --view expanded || true

echo
echo "== getPlatformAnalytics implementation =="
rg -n -A120 -B20 "export const getPlatformAnalytics" src/controllers/analytics/analyticsController.js

Repository: Deen-Bridge/dnb-backend

Length of output: 6668


Split the analytics routes into dedicated handlers
These four routes all invoke getPlatformAnalytics, so every request runs the full set of aggregations and returns the same combined response. Split them into route-specific handlers, or pass a view/report param, so each endpoint computes and returns only what it advertises.

🤖 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/routes/analytics/analyticsRoutes.js` around lines 13 - 17, Update the
analytics route definitions using getPlatformAnalytics so each endpoint selects
a dedicated report/view for its advertised data: platform, timeseries,
top-educators, and top-items. Modify getPlatformAnalytics to honor that selector
and run/return only the corresponding aggregation, while preserving protect and
admin authorization.

router.get("/top-items", protect, authorizeRoles("admin"), getPlatformAnalytics);
router.get("/courses/:id/progress", protect, getCourseProgress);
router.post("/courses/:id/progress", protect, updateCourseProgress);
router.get("/users/me/learning", protect, getUserLearning);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Two independent "learning dashboard" implementations, exposed on two different routes. getUserLearning and getLearningDashboard both query CourseProgress by user, sort by updatedAt, filter by percentComplete vs status, and map to a similar courses payload — but they aren't the same code, and only one of them (userController.js) uses .lean() with a projected populate(). Having both live on is a maintenance trap: a future bug fix (e.g., status filter edge case) applied to one will silently miss the other, and clients now have two URLs returning near-identical data.

  • src/controllers/analytics/analyticsController.js#L253-278: remove getUserLearning (or make it a thin re-export/delegate) and drop the redundant /users/me/learning route in favor of the userController.js implementation.
  • src/controllers/userController.js#L534-566: keep this as the single canonical implementation (already uses .lean() + field-projected populate()).
  • src/routes/analytics/analyticsRoutes.js#L20: remove this route, or have it simply forward to getLearningDashboard.
  • src/routes/userRoutes.js#L129: keep as the sole /me/learning endpoint.
📍 Affects 2 files
  • src/routes/analytics/analyticsRoutes.js#L20-L20 (this comment)
  • src/routes/userRoutes.js#L129-L129
🤖 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/routes/analytics/analyticsRoutes.js` at line 20, The learning dashboard
is duplicated across analytics and user routes. Remove the analytics route using
getUserLearning and its redundant controller implementation, while keeping
getLearningDashboard in userController.js as the canonical implementation and
preserving the sole /me/learning endpoint in src/routes/userRoutes.js at line
129; no direct change is needed there.

Comment thread test/analytics.test.js
Comment on lines +42 to +44
educatorToken = jwt.sign({ userId: educator._id, sessionId: "s1" }, process.env.JWT_SECRET || "deenbridge-temp-secret-key-2024");
adminToken = jwt.sign({ userId: admin._id, sessionId: "s2" }, process.env.JWT_SECRET || "deenbridge-temp-secret-key-2024");
studentToken = jwt.sign({ userId: student._id, sessionId: "s3" }, process.env.JWT_SECRET || "deenbridge-temp-secret-key-2024");

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Hardcoded JWT secret fallback in test source.

.env.example documents that JWT_SECRET should be a strong, dedicated env var (min 32 chars) for tests. Hardcoding the literal secret as a fallback here means the test suite can silently sign valid tokens even if JWT_SECRET is missing/misconfigured in the test environment, masking a real config problem and duplicating a secret value in source control.

🔧 Suggested fix
-    educatorToken = jwt.sign({ userId: educator._id, sessionId: "s1" }, process.env.JWT_SECRET || "deenbridge-temp-secret-key-2024");
+    educatorToken = jwt.sign({ userId: educator._id, sessionId: "s1" }, process.env.JWT_SECRET);

Fail fast (e.g., assert process.env.JWT_SECRET in a setup step) instead of silently falling back.

As per path instructions for `**/*.js`: "Flag hardcoded secrets, connection strings, JWT secrets, or wallet keys; all configuration belongs in environment variables documented in .env.example."

Also applies to: 73-75

🤖 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 `@test/analytics.test.js` around lines 42 - 44, Remove the hardcoded fallback
from the token setup around educatorToken, adminToken, and studentToken.
Validate that process.env.JWT_SECRET is present before signing tokens, failing
fast with a clear configuration error when it is missing, and pass the validated
environment value to jwt.sign for all test tokens.

Source: Path instructions

@zeemscript
zeemscript changed the base branch from main to dev July 29, 2026 11:44
@zeemscript

Copy link
Copy Markdown
Collaborator

@Myart352 fix conflicts please

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Enhancement] On-chain revenue analytics API: platform volume, top educators, and per-educator earnings from settled payments

2 participants