Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions BackendAcademy/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,18 @@ 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';
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';
Expand Down Expand Up @@ -58,6 +57,7 @@ import { SessionsModule } from './sessions/sessions.module';
OnboardingModule,
LessonModule,
TaskModule,
CourseModule,
AssetsModule,
JobsModule,
LoggingModule,
Expand Down
3 changes: 3 additions & 0 deletions BackendAcademy/src/courses/course-revision.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions BackendAcademy/src/courses/course.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
12 changes: 12 additions & 0 deletions BackendAcademy/src/courses/course.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];

Expand Down Expand Up @@ -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.
Expand Down
8 changes: 4 additions & 4 deletions BackendAcademy/src/courses/course.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
28 changes: 23 additions & 5 deletions BackendAcademy/src/courses/course.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,9 @@ export class CourseService {
private readonly courseRepo: Repository<CourseEntity>,
@InjectRepository(CourseRevisionEntity)
private readonly revisionRepo: Repository<CourseRevisionEntity>,
private readonly rewardsService: RewardsService,
) {}

constructor(private readonly rewardsService: RewardsService) {}

async create(dto: CreateCourseDto): Promise<CourseEntity> {
const course = this.courseRepo.create({
id: crypto.randomUUID(),
Expand Down Expand Up @@ -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', {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -266,4 +272,16 @@ export class CourseService {
progression: result,
};
}

private syncCourseTaxonomy(
course: CourseEntity,
dto: Pick<UpdateCourseDto, 'category' | 'categories'>,
): void {
if (dto.category && !dto.categories) {
course.categories = [dto.category];
}
if (dto.categories?.length && !dto.category) {
course.category = dto.categories[0];
}
}
}
14 changes: 14 additions & 0 deletions BackendAcademy/src/courses/dto/create-course.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down
14 changes: 14 additions & 0 deletions BackendAcademy/src/courses/dto/update-course.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down
3 changes: 3 additions & 0 deletions BackendAcademy/src/courses/interfaces/course.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
45 changes: 45 additions & 0 deletions BackendAcademy/src/search/dto/search-courses-query.dto.ts
Original file line number Diff line number Diff line change
@@ -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';
}
3 changes: 3 additions & 0 deletions BackendAcademy/src/search/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { SearchModule } from './search.module';
export { SearchService } from './search.service';
export { SearchCoursesQueryDto } from './dto/search-courses-query.dto';
9 changes: 6 additions & 3 deletions BackendAcademy/src/search/search.controller.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -29,10 +30,12 @@ export class SearchController {

/**
* GET /search/courses?q=<query>&limit=&offset=
* Substring search across courseId, title, description.
* Substring search across courseId, title, description, tags, and categories.
*/
@Get('courses')
searchCourses(@Query() query: SearchQueryDto): SearchResults<CourseSearchHit> {
searchCourses(
@Query() query: SearchCoursesQueryDto,
): Promise<SearchResults<CourseEntity>> {
return this.searchService.searchCourses(query);
}

Expand Down
2 changes: 2 additions & 0 deletions BackendAcademy/src/search/search.module.ts
Original file line number Diff line number Diff line change
@@ -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],
Expand Down
77 changes: 77 additions & 0 deletions BackendAcademy/src/search/search.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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<CourseEntity>) {
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 })]);
});
});
Loading
Loading