diff --git a/client/src/api/endpoints/course/course.ts b/client/src/api/endpoints/course/course.ts index 6c96e5a..e6bb5a5 100644 --- a/client/src/api/endpoints/course/course.ts +++ b/client/src/api/endpoints/course/course.ts @@ -8,6 +8,7 @@ import { getIndividualCourseService, getRecommendedCoursesService, getTrendingCoursesService, + searchCourseService, } from "../../services/course/courseService"; import { getCoursesByInstructorService } from "../../services/course/courseService"; import { PaymentIntent } from "@stripe/stripe-js"; @@ -50,3 +51,11 @@ export const getTrendingCourses = () => { export const getCourseByStudent = () => { return getCourseByStudentService(END_POINTS.GET_COURSE_BY_STUDENT); }; + +export const searchCourse = (searchQuery: string, filterQuery: string) => { + return searchCourseService( + END_POINTS.SEARCH_COURSE, + searchQuery, + filterQuery + ); +}; diff --git a/client/src/api/services/course/courseService.ts b/client/src/api/services/course/courseService.ts index 50076a8..3b41846 100644 --- a/client/src/api/services/course/courseService.ts +++ b/client/src/api/services/course/courseService.ts @@ -3,7 +3,10 @@ import api from "../../middlewares/protectedInterceptor"; import { PaymentIntent } from "@stripe/stripe-js"; import axiosInstance from "../../middlewares/interceptor"; -export const addCourseService = async (endpoint: string, courseInfo: FormData) => { +export const addCourseService = async ( + endpoint: string, + courseInfo: FormData +) => { const response = await api.post( `${CONSTANTS_COMMON.API_BASE_URL}/${endpoint}`, courseInfo @@ -11,7 +14,11 @@ export const addCourseService = async (endpoint: string, courseInfo: FormData) = return response; }; -export const editCourseService = async (endpoint: string,courseId:string, courseInfo: FormData) => { +export const editCourseService = async ( + endpoint: string, + courseId: string, + courseInfo: FormData +) => { const response = await api.put( `${CONSTANTS_COMMON.API_BASE_URL}/${endpoint}/${courseId}`, courseInfo @@ -44,41 +51,45 @@ export const getIndividualCourseService = async ( }; export const enrollStudentService = async ( - endpoint:string, - courseId:string, - paymentInfo?:PaymentIntent, + endpoint: string, + courseId: string, + paymentInfo?: PaymentIntent ) => { const response = await api.post( - `${CONSTANTS_COMMON.API_BASE_URL}/${endpoint}/${courseId}`,paymentInfo + `${CONSTANTS_COMMON.API_BASE_URL}/${endpoint}/${courseId}`, + paymentInfo ); - return response.data + return response.data; }; -export const getRecommendedCoursesService = async ( - endpoint:string -) => { +export const getRecommendedCoursesService = async (endpoint: string) => { const response = await api.get( `${CONSTANTS_COMMON.API_BASE_URL}/${endpoint}` ); - return response.data + return response.data; }; -export const getTrendingCoursesService = async ( - endpoint:string, -) => { +export const getTrendingCoursesService = async (endpoint: string) => { const response = await axiosInstance.get( `${CONSTANTS_COMMON.API_BASE_URL}/${endpoint}` ); - return response.data + return response.data; }; -export const getCourseByStudentService = async ( - endpoint:string, -) => { +export const getCourseByStudentService = async (endpoint: string) => { const response = await api.get( `${CONSTANTS_COMMON.API_BASE_URL}/${endpoint}` ); - return response.data + return response.data; }; - +export const searchCourseService = async ( + endpoint: string, + searchQuery: string, + filterQuery: string +) => { + const response = await api.get( + `${CONSTANTS_COMMON.API_BASE_URL}/${endpoint}?search=${searchQuery}&filter=${filterQuery}` + ); + return response.data; +}; diff --git a/client/src/constants/endpoints.ts b/client/src/constants/endpoints.ts index d4fd64f..7ec1b51 100644 --- a/client/src/constants/endpoints.ts +++ b/client/src/constants/endpoints.ts @@ -55,5 +55,6 @@ const END_POINTS = { GET_MY_STUDENTS:"api/instructors/get-students-by-instructor", GET_INSTRUCTOR_DETAILS:"api/instructors/get-instructor-details", EDIT_COURSE:"api/courses/instructors/edit-course", + SEARCH_COURSE:"api/courses/search-course" } export default END_POINTS \ No newline at end of file diff --git a/dump.rdb b/dump.rdb new file mode 100644 index 0000000..c784103 Binary files /dev/null and b/dump.rdb differ diff --git a/server/src/adapters/controllers/courseController.ts b/server/src/adapters/controllers/courseController.ts index fab0b13..678f146 100644 --- a/server/src/adapters/controllers/courseController.ts +++ b/server/src/adapters/controllers/courseController.ts @@ -45,6 +45,10 @@ import { } from '../../app/usecases/course/recommendation'; import { editCourseU } from '../../app/usecases/course/editCourse'; import { editLessonsU } from '../../app/usecases/lessons/editLesson'; +import { searchCourseU } from '../../app/usecases/course/search'; +import { CacheRepositoryInterface } from '@src/app/repositories/cachedRepoInterface'; +import { RedisRepositoryImpl } from '@src/frameworks/database/redis/redisCacheRepository'; +import { RedisClient } from '@src/app'; const courseController = ( cloudServiceInterface: CloudServiceInterface, @@ -58,7 +62,10 @@ const courseController = ( discussionDbRepository: DiscussionDbInterface, discussionDbRepositoryImpl: DiscussionRepoMongodbInterface, paymentDbRepository: PaymentInterface, - paymentDbRepositoryImpl: PaymentImplInterface + paymentDbRepositoryImpl: PaymentImplInterface, + cacheDbRepository: CacheRepositoryInterface, + cacheDbRepositoryImpl: RedisRepositoryImpl, + cacheClient: RedisClient ) => { const dbRepositoryCourse = courseDbRepository(courseDbRepositoryImpl()); const cloudService = cloudServiceInterface(cloudServiceImpl()); @@ -67,8 +74,10 @@ const courseController = ( const dbRepositoryDiscussion = discussionDbRepository( discussionDbRepositoryImpl() ); - const dbRepositoryPayment = paymentDbRepository(paymentDbRepositoryImpl()); + const dbRepositoryCache = cacheDbRepository( + cacheDbRepositoryImpl(cacheClient) + ); const addCourse = asyncHandler( async (req: CustomRequest, res: Response, next: NextFunction) => { @@ -91,30 +100,34 @@ const courseController = ( } ); - const editCourse = asyncHandler( - async (req: CustomRequest, res: Response) => { - const course: EditCourseInfo = req.body; - const files: Express.Multer.File[] = req.files as Express.Multer.File[]; - const instructorId = req.user?.Id; - const courseId: string = req.params.courseId; - const response = await editCourseU( - courseId, - instructorId, - files, - course, - cloudService, - dbRepositoryCourse - ); - res.status(200).json({ - status: 'success', - message: 'Successfully updated the course', - data: response - }); - } - ); + const editCourse = asyncHandler(async (req: CustomRequest, res: Response) => { + const course: EditCourseInfo = req.body; + const files: Express.Multer.File[] = req.files as Express.Multer.File[]; + const instructorId = req.user?.Id; + const courseId: string = req.params.courseId; + const response = await editCourseU( + courseId, + instructorId, + files, + course, + cloudService, + dbRepositoryCourse + ); + res.status(200).json({ + status: 'success', + message: 'Successfully updated the course', + data: response + }); + }); const getAllCourses = asyncHandler(async (req: Request, res: Response) => { const courses = await getAllCourseU(cloudService, dbRepositoryCourse); + const cacheOptions = { + key: `all-courses`, + expireTimeSec: 600, + data: JSON.stringify(courses) + }; + await dbRepositoryCache.setCache(cacheOptions); res.status(200).json({ status: 'success', message: 'Successfully retrieved all courses', @@ -155,7 +168,7 @@ const courseController = ( ); const addLesson = asyncHandler(async (req: CustomRequest, res: Response) => { - const instructorId = req.user?.Id + const instructorId = req.user?.Id; const courseId = req.params.courseId; const lesson = req.body; const medias = req.files as Express.Multer.File[]; @@ -179,7 +192,7 @@ const courseController = ( const editLesson = asyncHandler(async (req: CustomRequest, res: Response) => { const lesson = req.body; - const lessonId = req.params.lessonId + const lessonId = req.params.lessonId; const medias = req.files as Express.Multer.File[]; const questions = JSON.parse(lesson.questions); lesson.questions = questions; @@ -382,6 +395,30 @@ const courseController = ( } ); + const searchCourse = asyncHandler(async (req: Request, res: Response) => { + const { search, filter } = req.query as { search: string; filter: string }; + const key = search.trim()===""?search:filter + const searchResult = await searchCourseU( + search, + filter, + cloudService, + dbRepositoryCourse + ); + if (searchResult.length) { + const cacheOptions = { + key: `${key}`, + expireTimeSec: 600, + data: JSON.stringify(searchResult) + }; + await dbRepositoryCache.setCache(cacheOptions); + } + res.status(200).json({ + status: 'success', + message: 'Successfully retrieved courses based on the search query', + data: searchResult + }); + }); + return { addCourse, editCourse, @@ -402,7 +439,8 @@ const courseController = ( enrollStudent, getRecommendedCourseByStudentInterest, getTrendingCourses, - getCourseByStudent + getCourseByStudent, + searchCourse }; }; diff --git a/server/src/app/repositories/cachedRepoInterface.ts b/server/src/app/repositories/cachedRepoInterface.ts index 71572c2..6e7c94d 100644 --- a/server/src/app/repositories/cachedRepoInterface.ts +++ b/server/src/app/repositories/cachedRepoInterface.ts @@ -1,6 +1,6 @@ -import { RedisRepository} from "../../frameworks/database/redis/cache" +import { RedisRepositoryImpl} from "../../frameworks/database/redis/redisCacheRepository" -export const cacheRepositoryInterface=(repository:ReturnType)=>{ +export const cacheRepositoryInterface=(repository:ReturnType)=>{ const setCache = async(cachingOptions:{ key: string; diff --git a/server/src/app/repositories/courseDbRepository.ts b/server/src/app/repositories/courseDbRepository.ts index 8f3a73e..770c4d5 100644 --- a/server/src/app/repositories/courseDbRepository.ts +++ b/server/src/app/repositories/courseDbRepository.ts @@ -1,13 +1,17 @@ import { CourseRepositoryMongoDbInterface } from '@src/frameworks/database/mongodb/repositories/courseReposMongoDb'; -import { AddCourseInfoInterface, EditCourseInfo } from '@src/types/courseInterface'; +import { + AddCourseInfoInterface, + EditCourseInfo +} from '@src/types/courseInterface'; export const courseDbRepository = ( repository: ReturnType ) => { const addCourse = async (courseInfo: AddCourseInfoInterface) => await repository.addCourse(courseInfo); - - const editCourse = async (courseId:string,editInfo:EditCourseInfo)=> await repository.editCourse(courseId,editInfo) + + const editCourse = async (courseId: string, editInfo: EditCourseInfo) => + await repository.editCourse(courseId, editInfo); const getAllCourse = async () => await repository.getAllCourse(); @@ -40,6 +44,9 @@ export const courseDbRepository = ( const getStudentsByCourseForInstructor = async (instructorId: string) => await repository.getStudentsByCourseForInstructor(instructorId); + const searchCourse = async (isFree: boolean, searchQuery: string,filterQuery:string) => + await repository.searchCourse(isFree, searchQuery,filterQuery); + return { addCourse, editCourse, @@ -53,7 +60,8 @@ export const courseDbRepository = ( getCourseByStudent, getTotalNumberOfCourses, getNumberOfCoursesAddedInEachMonth, - getStudentsByCourseForInstructor + getStudentsByCourseForInstructor, + searchCourse }; }; export type CourseDbRepositoryInterface = typeof courseDbRepository; diff --git a/server/src/app/usecases/course/search.ts b/server/src/app/usecases/course/search.ts new file mode 100644 index 0000000..e21e525 --- /dev/null +++ b/server/src/app/usecases/course/search.ts @@ -0,0 +1,49 @@ +import { CourseDbRepositoryInterface } from '../../../app/repositories/courseDbRepository'; +import AppError from '../../../utils/appError'; +import HttpStatusCodes from '../../../constants/HttpStatusCodes'; +import { CourseInterface } from '../../../types/courseInterface'; +import { CloudServiceInterface } from '@src/app/services/cloudServiceInterface'; + +export const searchCourseU = async ( + searchQuery: string, + filterQuery: string, + cloudService:ReturnType, + courseDbRepository: ReturnType +) => { + if (!searchQuery && !filterQuery) { + throw new AppError( + 'Please provide a search or filter query', + HttpStatusCodes.BAD_REQUEST + ); + } + let isFree = false + let searchParams: string; + + if (searchQuery) { + // Check if the search query has the "free" prefix + const freeRegex = /^free\s/i; + const isFreeMatch = searchQuery.match(freeRegex); + if (isFreeMatch) { + isFree = true; + searchParams = searchQuery.replace(freeRegex, '').trim(); + } else { + searchParams = searchQuery; + } + } else { + searchParams = filterQuery; + } + + const searchResult= await courseDbRepository.searchCourse( + isFree, + searchParams, + filterQuery + ); + await Promise.all( + searchResult.map(async (course) => { + if (course.thumbnail) { + course.thumbnailUrl = await cloudService.getFile(course.thumbnail.key); + } + }) + ); + return searchResult; +}; diff --git a/server/src/frameworks/database/mongodb/models/course.ts b/server/src/frameworks/database/mongodb/models/course.ts index cfea280..1694042 100644 --- a/server/src/frameworks/database/mongodb/models/course.ts +++ b/server/src/frameworks/database/mongodb/models/course.ts @@ -1,20 +1,20 @@ import mongoose, { Schema, model } from 'mongoose'; import { AddCourseInfoInterface } from '@src/types/courseInterface'; -const FileSchema = new Schema ({ - name:{ - type:String, - required:true - }, - key:{ - type:String, - required:true - }, - url:{ - type:String, - } -}) -const courseSchema = new Schema({ +const FileSchema = new Schema({ + name: { + type: String, + required: true + }, + key: { + type: String, + required: true + }, + url: { + type: String + } +}); +const courseSchema = new mongoose.Schema({ title: { type: String, required: true, @@ -22,12 +22,12 @@ const courseSchema = new Schema({ maxlength: 100 }, instructorId: { - type: Schema.Types.ObjectId, + type: mongoose.Schema.Types.ObjectId, required: true }, duration: { type: Number, - required:true, + required: true, min: 0 }, category: { @@ -38,52 +38,52 @@ const courseSchema = new Schema({ type: String, required: true }, - tags:{ - type:Array, - required:true + tags: { + type: [String], + required: true }, price: { type: Number, - required: function(this:AddCourseInfoInterface) { + required: function (this: AddCourseInfoInterface) { return this.isPaid; }, - min: 0 + min: 0, }, isPaid: { type: Boolean, required: true }, - about:{ - type:String, - required:true + about: { + type: String, + required: true }, description: { type: String, required: true, minlength: 10 }, - syllabus:{ - type:Array, - required:true + syllabus: { + type: [String], + required: true }, requirements: { type: [String] }, - thumbnail:{ - type:FileSchema, - required:true, + thumbnail: { + type: FileSchema, + required: true }, - thumbnailUrl:{ - type:String, - default:"" + thumbnailUrl: { + type: String, + default: '' }, - guidelines:{ - type:FileSchema, - required:true, + guidelines: { + type: FileSchema, + required: true }, - guidelinesUrl:{ - type:String, - default:"" + guidelinesUrl: { + type: String, + default: '' }, coursesEnrolled: [ { @@ -95,11 +95,11 @@ const courseSchema = new Schema({ type: Number, min: 0, max: 5, - default:0 + default: 0 }, isVerified: { type: Boolean, - default:false, + default: false }, createdAt: { type: Date, @@ -109,9 +109,26 @@ const courseSchema = new Schema({ type: Number, min: 0, max: 100, - default:0 + default: 0 } }); +// courseSchema.index( +// { +// title: 'text', +// category: 'text', +// level: 'text', +// price: 'text' +// }, +// { +// weights: { +// title: 4, +// category: 3, +// level: 2, +// price: 1 +// } +// } +// ); + const Course = model('Course', courseSchema, 'course'); export default Course; diff --git a/server/src/frameworks/database/mongodb/repositories/courseReposMongoDb.ts b/server/src/frameworks/database/mongodb/repositories/courseReposMongoDb.ts index 9377c38..52f5bb6 100644 --- a/server/src/frameworks/database/mongodb/repositories/courseReposMongoDb.ts +++ b/server/src/frameworks/database/mongodb/repositories/courseReposMongoDb.ts @@ -1,5 +1,5 @@ import Course from '../models/course'; -import mongoose from 'mongoose'; +import mongoose, { FilterQuery } from 'mongoose'; import Students from '../models/student'; import { AddCourseInfoInterface, @@ -20,7 +20,7 @@ export const courseRepositoryMongodb = () => { { _id: new mongoose.Types.ObjectId(courseId) }, { ...editInfo } ); - return response + return response; }; const getAllCourse = async () => { @@ -223,6 +223,34 @@ export const courseRepositoryMongodb = () => { return students; }; + const searchCourse = async ( + isFree: boolean, + searchQuery: string, + filterQuery: string + ) => { + let query = {}; + if (searchQuery && filterQuery) { + query = { + $and: [ + { $text: { $search: searchQuery } }, + { isFree: isFree }, + ], + }; + } + else if (searchQuery) { + query = { $text: { $search: searchQuery } }; + } + else if (filterQuery) { + query = { isFree: isFree }; + } + const courses = await Course.find(query, { + score: { $meta: "textScore" }, + }).sort({ score: { $meta: "textScore" } }); + + return courses; + }; + + return { addCourse, editCourse, @@ -236,7 +264,8 @@ export const courseRepositoryMongodb = () => { getCourseByStudent, getTotalNumberOfCourses, getNumberOfCoursesAddedInEachMonth, - getStudentsByCourseForInstructor + getStudentsByCourseForInstructor, + searchCourse }; }; diff --git a/server/src/frameworks/database/redis/redisCacheRepository.ts b/server/src/frameworks/database/redis/redisCacheRepository.ts new file mode 100644 index 0000000..31914e5 --- /dev/null +++ b/server/src/frameworks/database/redis/redisCacheRepository.ts @@ -0,0 +1,38 @@ +import { CourseInterface } from '@src/types/courseInterface'; +import { RedisClient } from '../../../app'; + +export function redisCacheRepository(redisClient: RedisClient) { + const setCache = async ({ + key, + expireTimeSec, + data + }: { + key: string; + expireTimeSec: number; + data: string; + }) => await redisClient.setEx(key, expireTimeSec, data); + + const populateTrie = async (course: CourseInterface) => { + const trie: { [key: string]: any } = {}; // Initialize the trie object + + const title = course.title.toLowerCase(); + let currentNode: { [key: string]: any } = trie; + + for (const char of title) { + if (!currentNode[char]) { + currentNode[char] = {}; // Create a child node for the character + } + currentNode = currentNode[char]; // Move to the next node + } + + currentNode['*'] = course.title; // Mark the end of the course title with '*' + redisClient.set('course-trie', JSON.stringify(trie)); // Store the trie in Redis + }; + + return { + setCache, + populateTrie + }; +} + +export type RedisRepositoryImpl = typeof redisCacheRepository; diff --git a/server/src/frameworks/webserver/middlewares/redisCaching.ts b/server/src/frameworks/webserver/middlewares/redisCaching.ts index dbdf9f8..ac8c543 100644 --- a/server/src/frameworks/webserver/middlewares/redisCaching.ts +++ b/server/src/frameworks/webserver/middlewares/redisCaching.ts @@ -1,14 +1,15 @@ import { NextFunction, Request, Response } from "express"; import { RedisClient } from "../../../app"; -export function redisCachingMiddleware(redisClient:RedisClient, key:string) { +export function cachingMiddleware(redisClient:RedisClient, key?:string) { return async function (req:Request, res:Response, next:NextFunction) { - const params = req.params.id; - const data =await redisClient.get(`${key}-${params}`) + const { search, filter } = req.query as { search: string; filter: string }; + const searchKey = search?search:filter + const data =await redisClient.get(searchKey??key) if(!data){ return next() }else{ - res.json(JSON.parse(data)) + res.json({data:JSON.parse(data)}) } }; } diff --git a/server/src/frameworks/webserver/routes/course.ts b/server/src/frameworks/webserver/routes/course.ts index 9cd3718..981054a 100644 --- a/server/src/frameworks/webserver/routes/course.ts +++ b/server/src/frameworks/webserver/routes/course.ts @@ -18,7 +18,12 @@ import { discussionRepositoryMongoDb } from '../../../frameworks/database/mongod import { paymentRepositoryMongodb } from '../../../frameworks/database/mongodb/repositories/paymentRepoMongodb'; import { paymentInterface } from '../../../app/repositories/paymentDbRepository'; import jwtAuthMiddleware from '../middlewares/userAuth'; -const courseRouter = () => { +import { redisCacheRepository } from '../../../frameworks/database/redis/redisCacheRepository'; +import { cacheRepositoryInterface } from '../../../app/repositories/cachedRepoInterface'; +import { RedisClient } from '../../../app'; +import { cachingMiddleware } from '../middlewares/redisCaching'; + +const courseRouter = (redisClient: RedisClient) => { const router = express.Router(); const controller = courseController( cloudServiceInterface, @@ -32,7 +37,10 @@ const courseRouter = () => { discussionDbRepository, discussionRepositoryMongoDb, paymentInterface, - paymentRepositoryMongodb + paymentRepositoryMongodb, + cacheRepositoryInterface, + redisCacheRepository, + redisClient ); //* Add course router.post( @@ -51,7 +59,11 @@ const courseRouter = () => { controller.editCourse ); - router.get('/get-all-courses', controller.getAllCourses); + router.get( + '/get-all-courses', + cachingMiddleware(redisClient, 'all-courses'), + controller.getAllCourses + ); router.get('/get-course/:courseId', controller.getIndividualCourse); @@ -142,6 +154,12 @@ const courseRouter = () => { controller.getCourseByStudent ); + router.get( + '/search-course', + cachingMiddleware(redisClient), + controller.searchCourse + ); + return router; }; export default courseRouter; diff --git a/server/src/frameworks/webserver/routes/index.ts b/server/src/frameworks/webserver/routes/index.ts index c00a592..11e1cd8 100644 --- a/server/src/frameworks/webserver/routes/index.ts +++ b/server/src/frameworks/webserver/routes/index.ts @@ -5,10 +5,7 @@ import courseRouter from './course'; import instructorRouter from './instructor'; import { RedisClient } from '../../../app'; import jwtAuthMiddleware from '../middlewares/userAuth'; -import { - adminRoleCheckMiddleware, - studentRoleCheckMiddleware -} from '../middlewares/roleCheckMiddleware'; +import { adminRoleCheckMiddleware } from '../middlewares/roleCheckMiddleware'; import videoStreamRouter from './videoStream'; import refreshRouter from './refresh'; import paymentRouter from './payment'; @@ -25,7 +22,7 @@ const routes = (app: Application, redisClient: RedisClient) => { adminRouter() ); app.use('/api/category', categoryRouter()); - app.use('/api/courses', courseRouter()); + app.use('/api/courses', courseRouter(redisClient)); app.use('/api/video-streaming', videoStreamRouter()); app.use('/api/instructors', instructorRouter()); app.use('/api/payments', jwtAuthMiddleware, paymentRouter());