diff --git a/BackendAcademy/src/app.module.ts b/BackendAcademy/src/app.module.ts index caade999b..75680a310 100644 --- a/BackendAcademy/src/app.module.ts +++ b/BackendAcademy/src/app.module.ts @@ -12,7 +12,6 @@ import { TutorProfileModule } from './users/tutor-profile.module'; import { ContractsModule } from './contracts/contracts.module'; import { UserProfileModule } from './users/user-profile.module'; import { AiModule } from './ai/ai.module'; -import { ContractsModule } from './contracts/contracts.module'; import { LeaderboardModule } from './leaderboard/leaderboard.module'; import { AnalyticsModule } from './analytics/analytics.module'; import { WalletModule } from './wallet/wallet.module'; @@ -20,11 +19,11 @@ 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 { JobsModule } from './jobs/jobs.module'; import { LoggingModule } from './logging/logging.module'; import { ProgressModule } from './courses/progress/progress.module'; import { AppConfigModule } from './config/config.module'; -import { ContractsModule } from './contracts/contracts.module'; import { AssetsModule } from './assets/assets.module'; import { PathfindingModule } from './pathfinding/pathfinding.module'; import { MonitoringModule } from './monitoring/monitoring.module'; @@ -58,6 +57,7 @@ import { SessionsModule } from './sessions/sessions.module'; OnboardingModule, LessonModule, TaskModule, + CourseModule, AssetsModule, JobsModule, LoggingModule, diff --git a/BackendAcademy/src/courses/course-revision.entity.ts b/BackendAcademy/src/courses/course-revision.entity.ts index ba74d118a..58dea2bdc 100644 --- a/BackendAcademy/src/courses/course-revision.entity.ts +++ b/BackendAcademy/src/courses/course-revision.entity.ts @@ -43,6 +43,9 @@ export class CourseRevisionEntity { order: number; learningPathId: string; duration: number; + category: string; + categories: string[]; + tags: string[]; prerequisites: string[]; skills: string[]; xpReward: number; diff --git a/BackendAcademy/src/courses/course.controller.ts b/BackendAcademy/src/courses/course.controller.ts index 884652bf0..9a1e8cc32 100644 --- a/BackendAcademy/src/courses/course.controller.ts +++ b/BackendAcademy/src/courses/course.controller.ts @@ -101,6 +101,8 @@ export class CourseController { Number(version), dto.revisionAuthor, ); + } + @Post(':id/complete') async complete(@Param('id') id: string, @Body() dto: CompleteCourseDto) { return this.courseService.completeCourse(id, dto.userId); diff --git a/BackendAcademy/src/courses/course.entity.ts b/BackendAcademy/src/courses/course.entity.ts index b0af96716..ebb72f913 100644 --- a/BackendAcademy/src/courses/course.entity.ts +++ b/BackendAcademy/src/courses/course.entity.ts @@ -42,6 +42,15 @@ export class CourseEntity { @Column({ type: 'int' }) duration: number; + @Column({ type: 'varchar', length: 120, default: 'general' }) + category: string; + + @Column({ type: 'text', array: true, default: () => "'{}'" }) + categories: string[]; + + @Column({ type: 'text', array: true, default: () => "'{}'" }) + tags: string[]; + @Column({ type: 'text', array: true, default: () => "'{}'" }) prerequisites: string[]; @@ -93,6 +102,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 || []; // Object.assign above already copies version; default to 1 when absent. diff --git a/BackendAcademy/src/courses/course.module.ts b/BackendAcademy/src/courses/course.module.ts index a01ef2d50..1aa97b511 100644 --- a/BackendAcademy/src/courses/course.module.ts +++ b/BackendAcademy/src/courses/course.module.ts @@ -4,13 +4,13 @@ import { CourseController } from './course.controller'; import { CourseService } from './course.service'; import { CourseEntity } from './course.entity'; import { CourseRevisionEntity } from './course-revision.entity'; - -@Module({ - imports: [TypeOrmModule.forFeature([CourseEntity, CourseRevisionEntity])], import { RewardsModule } from '../rewards/rewards.module'; @Module({ - imports: [RewardsModule], + imports: [ + TypeOrmModule.forFeature([CourseEntity, CourseRevisionEntity]), + RewardsModule, + ], controllers: [CourseController], providers: [CourseService], exports: [CourseService], diff --git a/BackendAcademy/src/courses/course.service.ts b/BackendAcademy/src/courses/course.service.ts index 9b96bc630..1c7285bdb 100644 --- a/BackendAcademy/src/courses/course.service.ts +++ b/BackendAcademy/src/courses/course.service.ts @@ -32,10 +32,9 @@ export class CourseService { private readonly courseRepo: Repository, @InjectRepository(CourseRevisionEntity) private readonly revisionRepo: Repository, + private readonly rewardsService: RewardsService, ) {} - constructor(private readonly rewardsService: RewardsService) {} - async create(dto: CreateCourseDto): Promise { const course = this.courseRepo.create({ id: crypto.randomUUID(), @@ -71,6 +70,7 @@ export class CourseService { course.version = previousVersion + 1; course.updatedAt = new Date(); Object.assign(course, dto); + this.syncCourseTaxonomy(course, dto); const saved = await this.courseRepo.save(course); await this.appendRevision(saved, 'update', { @@ -170,6 +170,9 @@ export class CourseService { course.order = target.order; course.learningPathId = target.learningPathId; course.duration = target.duration; + course.category = target.category; + course.categories = [...(target.categories ?? [])]; + course.tags = [...(target.tags ?? [])]; course.prerequisites = [...target.prerequisites]; course.skills = [...target.skills]; course.xpReward = target.xpReward; @@ -229,6 +232,9 @@ export class CourseService { order: course.order, learningPathId: course.learningPathId, duration: course.duration, + category: course.category, + categories: [...(course.categories ?? [])], + tags: [...(course.tags ?? [])], prerequisites: [...(course.prerequisites ?? [])], skills: [...(course.skills ?? [])], xpReward: course.xpReward, @@ -249,15 +255,15 @@ export class CourseService { } async completeCourse(id: string, userId: string) { - const course = this.courses.get(id); + const course = await this.courseRepo.findOne({ where: { id } }); if (!course) { throw new NotFoundException(`Course with ID ${id} not found.`); } - + // Reward the user for completing the course const xpReward = course.xpReward || 50; // Default to 50 XP if not specified const result = this.rewardsService.recordActivity(userId, new Date(), xpReward); - + return { message: 'Course completed successfully', courseId: id, @@ -266,4 +272,16 @@ export class CourseService { progression: result, }; } + + private syncCourseTaxonomy( + course: CourseEntity, + dto: Pick, + ): void { + if (dto.category && !dto.categories) { + course.categories = [dto.category]; + } + if (dto.categories?.length && !dto.category) { + course.category = dto.categories[0]; + } + } } 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 97cd4a522..5b7f51b73 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..939d3d02e --- /dev/null +++ b/BackendAcademy/src/search/dto/search-courses-query.dto.ts @@ -0,0 +1,45 @@ +import { Transform } from 'class-transformer'; +import { IsArray, IsIn, IsOptional, IsString } from 'class-validator'; +import { SearchQueryDto } from './search-query.dto'; + +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 extends SearchQueryDto { + @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 index 7c7fb4a7c..ff6b18f76 100644 --- a/BackendAcademy/src/search/search.controller.ts +++ b/BackendAcademy/src/search/search.controller.ts @@ -1,12 +1,13 @@ import { Controller, Get, Query } from '@nestjs/common'; +import { SearchCoursesQueryDto } from './dto/search-courses-query.dto'; import { SearchService } from './search.service'; import { SearchQueryDto } from './dto/search-query.dto'; import { - CourseSearchHit, PostSearchHit, SearchResults, UserSearchHit, } from './interfaces/search.interface'; +import { CourseEntity } from '../courses/course.entity'; /** * Multi-resource search controller. @@ -29,10 +30,12 @@ export class SearchController { /** * GET /search/courses?q=&limit=&offset= - * Substring search across courseId, title, description. + * Substring search across courseId, title, description, tags, and categories. */ @Get('courses') - searchCourses(@Query() query: SearchQueryDto): SearchResults { + searchCourses( + @Query() query: SearchCoursesQueryDto, + ): Promise> { return this.searchService.searchCourses(query); } diff --git a/BackendAcademy/src/search/search.module.ts b/BackendAcademy/src/search/search.module.ts index fa74573df..8c8eb0ee1 100644 --- a/BackendAcademy/src/search/search.module.ts +++ b/BackendAcademy/src/search/search.module.ts @@ -1,8 +1,10 @@ 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], exports: [SearchService], 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 index e51988c1e..b6d672921 100644 --- a/BackendAcademy/src/search/search.service.ts +++ b/BackendAcademy/src/search/search.service.ts @@ -1,7 +1,9 @@ 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'; import { SearchQueryDto } from './dto/search-query.dto'; import { - CourseSearchHit, PostSearchHit, SearchResults, UserSearchHit, @@ -13,6 +15,8 @@ export class SearchService { private static readonly MAX_LIMIT = 50; private static readonly DEFAULT_LIMIT = 10; + constructor(private readonly courseService: CourseService) {} + /** * In-memory fixture set. Replace with a real SearchRepository backed by * Postgres `tsvector` (or an external index like Meilisearch / pg_trgm). @@ -30,34 +34,6 @@ export class SearchService { { id: 'user-0008', username: 'rust-newbie', displayName: 'Rust Newbie' }, ]; - private readonly courses: CourseSearchHit[] = [ - { - id: 'course-001', - title: 'Rust Fundamentals', - description: 'Learn ownership, borrowing, and lifetimes.', - }, - { - id: 'course-002', - title: 'Stellar Smart Contracts', - description: 'Build Soroban contracts from scratch.', - }, - { - id: 'course-003', - title: 'Advanced Rust', - description: 'Async, traits, and macros deep-dive.', - }, - { - id: 'course-004', - title: 'Stellar Payments', - description: 'Send and receive XLM/USDC using Horizon.', - }, - { - id: 'course-005', - title: 'Rust for Web3', - description: 'Blockchain, NFTs, and on-chain Rust.', - }, - ]; - private readonly posts: PostSearchHit[] = [ { id: 'post-001', @@ -141,13 +117,51 @@ export class SearchService { ); } - searchCourses(query: SearchQueryDto): SearchResults { + 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'; + const filteredCourses = + tags.length === 0 && categories.length === 0 + ? courses + : courses.filter((course) => { + const courseTags = this.normalize(course.tags); + const courseCategories = this.normalize([ + course.category, + ...(course.categories ?? []), + ]); + const checks = [ + ...tags.map((tag) => courseTags.includes(tag)), + ...categories.map((category) => + courseCategories.includes(category), + ), + ]; + + return match === 'all' + ? checks.every(Boolean) + : checks.some(Boolean); + }); + return this.paginate( - this.courses, + filteredCourses, query.q, query.limit, query.offset, - (c) => `${c.id} ${c.title} ${c.description}`, + (c) => + [ + c.id, + c.title, + c.description, + c.category, + ...(c.categories ?? []), + ...(c.tags ?? []), + ].join(' '), ); } @@ -160,4 +174,10 @@ export class SearchService { (p) => `${p.id} ${p.title} ${p.body}`, ); } + + private normalize(values?: string[]): string[] { + return (values ?? []) + .map((value) => value.trim().toLowerCase()) + .filter(Boolean); + } }