11/**
22 * Courses Route
3- * Handles course content and version management endpoints
3+ * Handles course listing and version management endpoints
44 */
55
66const express = require ( 'express' ) ;
@@ -10,6 +10,161 @@ const { readLimiter, courseWriteLimiter } = require('../middleware/rateLimiter')
1010const Joi = require ( 'joi' ) ;
1111const { 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+
13168const contentIdParamSchema = {
14169 params : Joi . object ( {
15170 contentId : Joi . string ( ) . trim ( ) . min ( 1 ) . required ( ) ,
0 commit comments