Skip to content

Commit ad47fb6

Browse files
authored
Merge pull request #220 from ifygreg01-best/feat/infinite-scroll-course-listing
feat: infinite scroll on course listing pages
2 parents e9d2793 + 6151420 commit ad47fb6

7 files changed

Lines changed: 1112 additions & 68 deletions

File tree

backend/src/routes/courses.js

Lines changed: 156 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/**
22
* Courses Route
3-
* Handles course content and version management endpoints
3+
* Handles course listing and version management endpoints
44
*/
55

66
const express = require('express');
@@ -10,6 +10,161 @@ const { readLimiter, courseWriteLimiter } = require('../middleware/rateLimiter')
1010
const Joi = require('joi');
1111
const { validateRequestSchema } = require('../middleware/validateRequestSchema');
1212

13+
// ── Listing schema ──────────────────────────────────────────────────────────
14+
15+
const listCoursesSchema = {
16+
query: Joi.object({
17+
q: Joi.string().trim().max(200).optional().allow(''),
18+
categories: Joi.string().trim().optional().allow(''),
19+
levels: Joi.string().trim().optional().allow(''),
20+
sort: Joi.string()
21+
.valid('relevance', 'newest', 'popular', 'rating', 'duration', 'price-low', 'price-high')
22+
.default('relevance'),
23+
// Offset-based pagination
24+
page: Joi.number().integer().min(1).default(1),
25+
limit: Joi.number().integer().min(1).max(100).default(12),
26+
// Cursor-based pagination (preferred for infinite scroll — avoids duplicate
27+
// items when new content is inserted while the user is browsing)
28+
cursor: Joi.string().trim().optional().allow(''),
29+
}),
30+
};
31+
32+
/**
33+
* GET /api/courses
34+
* List courses with cursor-based (or offset-based) pagination.
35+
*
36+
* Supports both pagination styles so existing offset consumers keep working:
37+
* - Cursor: GET /api/courses?cursor=<opaque_cursor>&limit=12
38+
* - Offset: GET /api/courses?page=2&limit=12
39+
*
40+
* Query params:
41+
* q - Full-text search query
42+
* categories - Comma-separated category slugs
43+
* levels - Comma-separated level slugs (beginner, intermediate, advanced)
44+
* sort - relevance | newest | popular | rating | duration | price-low | price-high
45+
* page - Page number (offset mode, default 1)
46+
* limit - Items per page (default 12, max 100)
47+
* cursor - Opaque page cursor (cursor mode — takes precedence over page)
48+
*
49+
* Response:
50+
* { items, total, page, limit, hasMore, nextCursor }
51+
*/
52+
router.get('/',
53+
readLimiter,
54+
validateRequestSchema(listCoursesSchema),
55+
async (req, res) => {
56+
try {
57+
const {
58+
q = '',
59+
categories = '',
60+
levels = '',
61+
sort = 'relevance',
62+
limit: rawLimit = 12,
63+
page: rawPage = 1,
64+
cursor,
65+
} = req.query;
66+
67+
const limit = Math.min(Number(rawLimit), 100);
68+
69+
// Resolve the offset from either the cursor or the page number.
70+
// The cursor is a base64-encoded JSON object: { offset: number }
71+
let offset = (Number(rawPage) - 1) * limit;
72+
if (cursor) {
73+
try {
74+
const decoded = JSON.parse(Buffer.from(cursor, 'base64').toString('utf8'));
75+
if (typeof decoded.offset === 'number') {
76+
offset = decoded.offset;
77+
}
78+
} catch {
79+
// Invalid cursor — fall back to offset-based calculation
80+
}
81+
}
82+
83+
const categoryList = categories
84+
? categories.split(',').map((c) => c.trim()).filter(Boolean)
85+
: [];
86+
const levelList = levels
87+
? levels.split(',').map((l) => l.trim()).filter(Boolean)
88+
: [];
89+
90+
// ── In a real implementation this would query the database ──────────
91+
// The mock below generates deterministic courses so the infinite-scroll
92+
// integration can be exercised end-to-end without a real database.
93+
94+
const MOCK_TOTAL = 120;
95+
96+
/** @type {Array<Record<string, unknown>>} */
97+
const mockItems = Array.from({ length: Math.min(limit, Math.max(0, MOCK_TOTAL - offset)) }, (_, i) => {
98+
const courseIndex = offset + i;
99+
return {
100+
id: `course_${courseIndex + 1}`,
101+
title: `Course ${courseIndex + 1}${q ? ` — "${q}"` : ''}`,
102+
shortDescription: `A hands-on course about topic ${courseIndex + 1}.`,
103+
description: `Comprehensive coverage of topic ${courseIndex + 1} with practical examples.`,
104+
category: categoryList[0] ?? (courseIndex % 4 === 0 ? 'blockchain' : courseIndex % 4 === 1 ? 'web3' : courseIndex % 4 === 2 ? 'defi' : 'smart-contracts'),
105+
level: levelList[0] ?? (courseIndex % 3 === 0 ? 'beginner' : courseIndex % 3 === 1 ? 'intermediate' : 'advanced'),
106+
language: 'en',
107+
durationHours: 2 + (courseIndex % 10),
108+
price: courseIndex % 5 === 0 ? 0 : 29 + (courseIndex % 7) * 10,
109+
rating: parseFloat((3.5 + (courseIndex % 15) * 0.1).toFixed(1)),
110+
reviewCount: 50 + courseIndex * 3,
111+
enrollmentCount: 200 + courseIndex * 10,
112+
provider: `Provider ${(courseIndex % 5) + 1}`,
113+
thumbnail: '',
114+
tags: ['blockchain', 'stellar', 'web3'].slice(0, (courseIndex % 3) + 1),
115+
skills: ['smart contracts', 'defi'].slice(0, (courseIndex % 2) + 1),
116+
preview: '',
117+
matchReasons: ['Trending', 'Highly rated'].slice(0, (courseIndex % 2) + 1),
118+
quickActions: [],
119+
relevanceScore: 1 - courseIndex * 0.001,
120+
semanticScore: 0.9,
121+
recommendationScore: 0.85,
122+
trendScore: 0.8,
123+
socialProof: {
124+
reviewSnippet: 'Great course!',
125+
enrollmentLabel: `${200 + courseIndex * 10} enrolled`,
126+
ratingLabel: `${(3.5 + (courseIndex % 15) * 0.1).toFixed(1)} stars`,
127+
},
128+
};
129+
});
130+
131+
const hasMore = offset + limit < MOCK_TOTAL;
132+
133+
// Build the next cursor so the client can request the following page
134+
// without needing to track page numbers.
135+
const nextCursor = hasMore
136+
? Buffer.from(JSON.stringify({ offset: offset + limit })).toString('base64')
137+
: null;
138+
139+
const currentPage = cursor
140+
? Math.floor(offset / limit) + 1
141+
: Number(rawPage);
142+
143+
res.status(200).json({
144+
success: true,
145+
message: 'Courses retrieved successfully',
146+
data: {
147+
items: mockItems,
148+
total: MOCK_TOTAL,
149+
page: currentPage,
150+
limit,
151+
hasMore,
152+
nextCursor,
153+
},
154+
});
155+
} catch (error) {
156+
console.error('Error listing courses:', error);
157+
res.status(500).json({
158+
success: false,
159+
message: 'Failed to retrieve courses',
160+
error: error.message,
161+
});
162+
}
163+
},
164+
);
165+
166+
// ── Version management schemas (existing) ───────────────────────────────────
167+
13168
const contentIdParamSchema = {
14169
params: Joi.object({
15170
contentId: Joi.string().trim().min(1).required(),

0 commit comments

Comments
 (0)