From 48d732c93d04242fc9417cd9b652ffa5a15d5dc8 Mon Sep 17 00:00:00 2001 From: olawalebakaredapo Date: Mon, 29 Jun 2026 20:25:29 +0100 Subject: [PATCH] Add tagged course discovery endpoint --- BackendAcademy/src/app.module.ts | 6 +- BackendAcademy/src/courses/course.entity.ts | 6 ++ BackendAcademy/src/courses/course.service.ts | 6 ++ .../src/courses/dto/create-course.dto.ts | 14 ++++ .../src/courses/dto/update-course.dto.ts | 14 ++++ .../courses/interfaces/course.interface.ts | 3 + .../search/dto/search-courses-query.dto.ts | 44 +++++++++++ BackendAcademy/src/search/index.ts | 3 + .../src/search/search.controller.ts | 13 ++++ BackendAcademy/src/search/search.module.ts | 11 +++ .../src/search/search.service.spec.ts | 77 +++++++++++++++++++ BackendAcademy/src/search/search.service.ts | 47 +++++++++++ 12 files changed, 243 insertions(+), 1 deletion(-) create mode 100644 BackendAcademy/src/search/dto/search-courses-query.dto.ts create mode 100644 BackendAcademy/src/search/index.ts create mode 100644 BackendAcademy/src/search/search.controller.ts create mode 100644 BackendAcademy/src/search/search.module.ts create mode 100644 BackendAcademy/src/search/search.service.spec.ts create mode 100644 BackendAcademy/src/search/search.service.ts diff --git a/BackendAcademy/src/app.module.ts b/BackendAcademy/src/app.module.ts index 9305580c6..23c512175 100644 --- a/BackendAcademy/src/app.module.ts +++ b/BackendAcademy/src/app.module.ts @@ -16,6 +16,8 @@ import { SocialModule } from './social/social.module'; import { OnboardingModule } from './onboarding/onboarding.module'; import { LessonModule } from './lessons/lesson.module'; import { TaskModule } from './tasks/task.module'; +import { CourseModule } from './courses'; +import { SearchModule } from './search'; @Module({ imports: [ @@ -38,6 +40,8 @@ import { TaskModule } from './tasks/task.module'; OnboardingModule, LessonModule, TaskModule, + CourseModule, + SearchModule, ], controllers: [AppController], providers: [ @@ -48,4 +52,4 @@ import { TaskModule } from './tasks/task.module'; }, ], }) -export class AppModule {} \ No newline at end of file +export class AppModule {} diff --git a/BackendAcademy/src/courses/course.entity.ts b/BackendAcademy/src/courses/course.entity.ts index dbc78c6b7..3fab98ef1 100644 --- a/BackendAcademy/src/courses/course.entity.ts +++ b/BackendAcademy/src/courses/course.entity.ts @@ -8,6 +8,9 @@ export class CourseEntity { order: number; learningPathId: string; duration: number; + category: string; + categories: string[]; + tags: string[]; prerequisites: string[]; skills: string[]; xpReward: number; @@ -20,6 +23,9 @@ export class CourseEntity { this.createdAt = this.createdAt || new Date(); this.updatedAt = this.updatedAt || new Date(); this.isActive = this.isActive ?? true; + this.category = this.category || this.categories?.[0] || 'general'; + this.categories = this.categories || [this.category]; + this.tags = this.tags || []; this.prerequisites = this.prerequisites || []; this.skills = this.skills || []; } diff --git a/BackendAcademy/src/courses/course.service.ts b/BackendAcademy/src/courses/course.service.ts index 58010fbf1..47975a876 100644 --- a/BackendAcademy/src/courses/course.service.ts +++ b/BackendAcademy/src/courses/course.service.ts @@ -34,6 +34,12 @@ export class CourseService { const course = this.courses.get(id); if (!course) return null; Object.assign(course, dto, { updatedAt: new Date() }); + if (dto.category && !dto.categories) { + course.categories = [dto.category]; + } + if (dto.categories?.length && !dto.category) { + course.category = dto.categories[0]; + } return course; } diff --git a/BackendAcademy/src/courses/dto/create-course.dto.ts b/BackendAcademy/src/courses/dto/create-course.dto.ts index 4cfe53448..4d95634d2 100644 --- a/BackendAcademy/src/courses/dto/create-course.dto.ts +++ b/BackendAcademy/src/courses/dto/create-course.dto.ts @@ -20,6 +20,20 @@ export class CreateCourseDto { @IsNumber() duration: number; + @IsOptional() + @IsString() + category?: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + categories?: string[]; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + tags?: string[]; + @IsOptional() @IsArray() @IsString({ each: true }) diff --git a/BackendAcademy/src/courses/dto/update-course.dto.ts b/BackendAcademy/src/courses/dto/update-course.dto.ts index e832bb208..b6f4df078 100644 --- a/BackendAcademy/src/courses/dto/update-course.dto.ts +++ b/BackendAcademy/src/courses/dto/update-course.dto.ts @@ -26,6 +26,20 @@ export class UpdateCourseDto { @IsNumber() duration?: number; + @IsOptional() + @IsString() + category?: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + categories?: string[]; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + tags?: string[]; + @IsOptional() @IsArray() @IsString({ each: true }) diff --git a/BackendAcademy/src/courses/interfaces/course.interface.ts b/BackendAcademy/src/courses/interfaces/course.interface.ts index 645a6defe..ce1d07670 100644 --- a/BackendAcademy/src/courses/interfaces/course.interface.ts +++ b/BackendAcademy/src/courses/interfaces/course.interface.ts @@ -8,6 +8,9 @@ export interface ICourse { order: number; learningPathId: string; duration: number; + category: string; + categories: string[]; + tags: string[]; prerequisites: string[]; skills: string[]; xpReward: number; diff --git a/BackendAcademy/src/search/dto/search-courses-query.dto.ts b/BackendAcademy/src/search/dto/search-courses-query.dto.ts new file mode 100644 index 000000000..b0d29a603 --- /dev/null +++ b/BackendAcademy/src/search/dto/search-courses-query.dto.ts @@ -0,0 +1,44 @@ +import { Transform } from 'class-transformer'; +import { IsArray, IsIn, IsOptional, IsString } from 'class-validator'; + +function toStringArray(value: unknown): string[] | undefined { + if (value === undefined || value === null || value === '') { + return undefined; + } + + const values = Array.isArray(value) ? value : [value]; + return values + .flatMap(item => String(item).split(',')) + .map(item => item.trim()) + .filter(Boolean); +} + +export class SearchCoursesQueryDto { + @IsOptional() + @Transform(({ value }) => toStringArray(value)) + @IsArray() + @IsString({ each: true }) + tag?: string[]; + + @IsOptional() + @Transform(({ value }) => toStringArray(value)) + @IsArray() + @IsString({ each: true }) + tags?: string[]; + + @IsOptional() + @Transform(({ value }) => toStringArray(value)) + @IsArray() + @IsString({ each: true }) + category?: string[]; + + @IsOptional() + @Transform(({ value }) => toStringArray(value)) + @IsArray() + @IsString({ each: true }) + categories?: string[]; + + @IsOptional() + @IsIn(['any', 'all']) + match?: 'any' | 'all' = 'any'; +} diff --git a/BackendAcademy/src/search/index.ts b/BackendAcademy/src/search/index.ts new file mode 100644 index 000000000..22bf3585a --- /dev/null +++ b/BackendAcademy/src/search/index.ts @@ -0,0 +1,3 @@ +export { SearchModule } from './search.module'; +export { SearchService } from './search.service'; +export { SearchCoursesQueryDto } from './dto/search-courses-query.dto'; diff --git a/BackendAcademy/src/search/search.controller.ts b/BackendAcademy/src/search/search.controller.ts new file mode 100644 index 000000000..e448105dc --- /dev/null +++ b/BackendAcademy/src/search/search.controller.ts @@ -0,0 +1,13 @@ +import { Controller, Get, Query } from '@nestjs/common'; +import { SearchCoursesQueryDto } from './dto/search-courses-query.dto'; +import { SearchService } from './search.service'; + +@Controller('search') +export class SearchController { + constructor(private readonly searchService: SearchService) {} + + @Get('courses') + async searchCourses(@Query() query: SearchCoursesQueryDto) { + return this.searchService.searchCourses(query); + } +} diff --git a/BackendAcademy/src/search/search.module.ts b/BackendAcademy/src/search/search.module.ts new file mode 100644 index 000000000..29ff9258f --- /dev/null +++ b/BackendAcademy/src/search/search.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { CourseModule } from '../courses'; +import { SearchController } from './search.controller'; +import { SearchService } from './search.service'; + +@Module({ + imports: [CourseModule], + controllers: [SearchController], + providers: [SearchService], +}) +export class SearchModule {} diff --git a/BackendAcademy/src/search/search.service.spec.ts b/BackendAcademy/src/search/search.service.spec.ts new file mode 100644 index 000000000..b5fd1ccfc --- /dev/null +++ b/BackendAcademy/src/search/search.service.spec.ts @@ -0,0 +1,77 @@ +import { CourseEntity } from '../courses/course.entity'; +import { CourseLevel } from '../courses'; +import { CourseService } from '../courses/course.service'; +import { SearchService } from './search.service'; + +describe('SearchService', () => { + let courseService: CourseService; + let searchService: SearchService; + + beforeEach(() => { + courseService = new CourseService(); + searchService = new SearchService(courseService); + }); + + async function addCourse(partial: Partial) { + return courseService.create({ + title: partial.title ?? 'Rust Basics', + description: partial.description ?? 'Learn Rust fundamentals', + level: partial.level ?? CourseLevel.BEGINNER, + order: partial.order ?? 1, + learningPathId: partial.learningPathId ?? 'rust', + duration: partial.duration ?? 60, + category: partial.category, + categories: partial.categories, + tags: partial.tags, + prerequisites: partial.prerequisites, + skills: partial.skills, + xpReward: partial.xpReward, + }); + } + + it('finds active courses by tag or category', async () => { + const ownership = await addCourse({ + title: 'Ownership', + category: 'fundamentals', + tags: ['rust', 'ownership'], + }); + await addCourse({ + title: 'Web APIs', + category: 'backend', + tags: ['axum'], + }); + + await expect( + searchService.searchCourses({ + tags: ['ownership'], + categories: ['backend'], + }), + ).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: ownership.id }), + expect.objectContaining({ title: 'Web APIs' }), + ]), + ); + }); + + it('supports requiring all tag and category filters', async () => { + const matchingCourse = await addCourse({ + title: 'Async Rust', + categories: ['backend', 'systems'], + tags: ['rust', 'async'], + }); + await addCourse({ + title: 'Intro Rust', + category: 'fundamentals', + tags: ['rust'], + }); + + await expect( + searchService.searchCourses({ + tags: ['rust', 'async'], + categories: ['backend'], + match: 'all', + }), + ).resolves.toEqual([expect.objectContaining({ id: matchingCourse.id })]); + }); +}); diff --git a/BackendAcademy/src/search/search.service.ts b/BackendAcademy/src/search/search.service.ts new file mode 100644 index 000000000..c8af85aeb --- /dev/null +++ b/BackendAcademy/src/search/search.service.ts @@ -0,0 +1,47 @@ +import { Injectable } from '@nestjs/common'; +import { CourseEntity } from '../courses/course.entity'; +import { CourseService } from '../courses/course.service'; +import { SearchCoursesQueryDto } from './dto/search-courses-query.dto'; + +@Injectable() +export class SearchService { + constructor(private readonly courseService: CourseService) {} + + async searchCourses(query: SearchCoursesQueryDto): Promise { + const courses = await this.courseService.findAll(); + const tags = this.normalize([...(query.tag ?? []), ...(query.tags ?? [])]); + const categories = this.normalize([ + ...(query.category ?? []), + ...(query.categories ?? []), + ]); + const match = query.match ?? 'any'; + + if (tags.length === 0 && categories.length === 0) { + return courses; + } + + return courses.filter(course => { + const courseTags = this.normalize(course.tags); + const courseCategories = this.normalize([ + course.category, + ...(course.categories ?? []), + ]); + + const tagMatches = tags.map(tag => courseTags.includes(tag)); + const categoryMatches = categories.map( + category => courseCategories.includes(category), + ); + const checks = [...tagMatches, ...categoryMatches]; + + return match === 'all' + ? checks.every(Boolean) + : checks.some(Boolean); + }); + } + + private normalize(values?: string[]): string[] { + return (values ?? []) + .map(value => value.trim().toLowerCase()) + .filter(Boolean); + } +}