Skip to content

Commit cd1deb8

Browse files
authored
Merge pull request #340 from Vvictor-commits/feat/course-filtering-and-new-fields
feat(courses): add difficulty/tags/thumbnailUrl fields and search/fil…
2 parents be5e366 + 5de2c82 commit cd1deb8

15 files changed

Lines changed: 195 additions & 175 deletions

backend/src/certificates/certificates.module.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { Module } from '@nestjs/common';
22
import { TypeOrmModule } from '@nestjs/typeorm';
33
import { Certificate } from '../certificates/entities/certificate.entity';
4-
<<<<<<< HEAD
54
import { Course } from '../courses/entities/course.entity';
65
import { User } from '../users/entities/user.entity';
76
import { CertificateController } from './certificates.controller';

backend/src/certificates/certificates.service.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ import { VerifyCertificateDto } from './dto/verify-certificate.dto';
2121
import { NotificationsService } from '../notifications/notifications.service';
2222
import { NotificationType } from '../notifications/entities/notification.entity';
2323
import { ConfigService } from '@nestjs/config';
24-
<<<<<<< HEAD
2524
import { EmailService } from '../email/email.service';
2625
import { WebhooksService } from '../webhooks/webhooks.service';
2726
import { WebhookEvent } from '../webhooks/dto/create-webhook.dto';

backend/src/courses/courses.controller.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { AuthGuard } from '@nestjs/passport';
2020
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
2121
import { RolesGuard } from '../common/guards/roles.guard';
2222
import { PaginationDto } from '../common/dto/pagination.dto';
23+
import { CourseFilterDto } from './dto/course-filter.dto';
2324
import { CoursesService } from './courses.service';
2425
import { UserRole } from '../common/enums/user-role.enum';
2526
import { Roles } from '../common/decorators/roles.decorator';
@@ -113,24 +114,31 @@ export class CoursesController {
113114
return this.coursesService.getEnrolledCourses(req.user.id);
114115
}
115116

117+
@Get('tags')
118+
@ApiOperation({ summary: 'Get unique tags from published courses' })
119+
@ApiResponse({ status: 200, description: 'List of unique tags', type: [String] })
120+
async getTags(): Promise<string[]> {
121+
return this.coursesService.getUniqueTags();
122+
}
123+
116124
@Get()
117125
@UseGuards(OptionalJwtAuthGuard)
118126
@ApiOperation({ summary: 'Get paginated list of courses' })
119127
@ApiResponse({ status: 200, description: 'Courses retrieved successfully' })
120128
async findAll(
121129
@Req() req: RequestWithUser,
122-
@Query() pagination: PaginationDto,
130+
@Query() filters: CourseFilterDto,
123131
): Promise<{
124132
data: CourseResponseDto[];
125133
total: number;
126134
page: number;
127135
limit: number;
128136
totalPages: number;
129137
}> {
130-
const page = pagination.page ?? 1;
131-
const limit = pagination.limit ?? 10;
138+
const page = filters.page ?? 1;
139+
const limit = filters.limit ?? 10;
132140
const userId = req.user?.id;
133-
return this.coursesService.findAllPaginated(page, limit, userId);
141+
return this.coursesService.findAllPaginated(page, limit, userId, filters);
134142
}
135143

136144
@Get(':id/enrollment-status')

backend/src/courses/courses.service.ts

Lines changed: 72 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
} from './dto/enrollment-response.dto';
1818
import { NotificationsService } from '../notifications/notifications.service';
1919
import { NotificationType } from '../notifications/entities/notification.entity';
20+
import { CourseFilterDto } from './dto/course-filter.dto';
2021

2122
@Injectable()
2223
export class CoursesService {
@@ -43,24 +44,70 @@ export class CoursesService {
4344
page: number = 1,
4445
limit: number = 10,
4546
userId?: string,
47+
filters?: CourseFilterDto,
4648
): Promise<PaginatedResult<CourseResponseDto>> {
47-
return this.findAllPaginated(page, limit, userId);
49+
return this.findAllPaginated(page, limit, userId, filters);
4850
}
4951

5052
async findAllPaginated(
5153
page: number,
5254
limit: number,
5355
userId?: string,
56+
filters?: CourseFilterDto,
5457
): Promise<PaginatedResult<CourseResponseDto>> {
55-
const result = await this.paginationService.paginate<Course>(
56-
this.courseRepository,
57-
{ page, limit },
58-
{ where: { published: true }, order: { createdAt: 'DESC' } },
59-
);
58+
const { search, difficulty, tags, sortBy = 'createdAt', sortOrder = 'desc' } = filters ?? {};
59+
60+
const qb = this.courseRepository
61+
.createQueryBuilder('course')
62+
.leftJoin('course.registrations', 'reg')
63+
.addSelect('COUNT(reg.id)', 'enrollmentCount')
64+
.where('course.published = :published', { published: true })
65+
.groupBy('course.id')
66+
.skip((page - 1) * limit)
67+
.take(limit);
68+
69+
if (search) {
70+
qb.andWhere(
71+
'(LOWER(course.title) LIKE :search OR LOWER(course.description) LIKE :search)',
72+
{ search: `%${search.toLowerCase()}%` },
73+
);
74+
}
75+
76+
if (difficulty) {
77+
qb.andWhere('LOWER(course.difficulty) = :difficulty', {
78+
difficulty: difficulty.toLowerCase(),
79+
});
80+
}
81+
82+
if (tags) {
83+
const tagList = tags.split(',').map((t) => t.trim()).filter(Boolean);
84+
tagList.forEach((tag, i) => {
85+
qb.andWhere(`course.tags LIKE :tag${i}`, { [`tag${i}`]: `%${tag}%` });
86+
});
87+
}
88+
89+
const orderCol = ['title', 'difficulty'].includes(sortBy) ? sortBy : 'createdAt';
90+
qb.orderBy(`course.${orderCol}`, sortOrder.toUpperCase() as 'ASC' | 'DESC');
91+
92+
const [courses, total] = await qb.getManyAndCount();
93+
94+
// fetch enrollment counts via raw query for accuracy
95+
const courseIds = courses.map((c) => c.id);
96+
const countMap = new Map<string, number>();
97+
if (courseIds.length > 0) {
98+
const counts: { courseId: string; count: string }[] =
99+
await this.courseRegistrationRepository
100+
.createQueryBuilder('reg')
101+
.select('reg.courseId', 'courseId')
102+
.addSelect('COUNT(reg.id)', 'count')
103+
.where('reg.courseId IN (:...ids)', { ids: courseIds })
104+
.groupBy('reg.courseId')
105+
.getRawMany();
106+
counts.forEach((r) => countMap.set(r.courseId, parseInt(r.count, 10)));
107+
}
60108

61109
let enrolledIds = new Set<string>();
62-
if (userId && result.data.length > 0) {
63-
const courseIds = result.data.map((c) => c.id);
110+
if (userId && courseIds.length > 0) {
64111
const regs = await this.courseRegistrationRepository.find({
65112
where: { userId, courseId: In(courseIds) },
66113
select: ['courseId'],
@@ -69,17 +116,30 @@ export class CoursesService {
69116
}
70117

71118
return {
72-
...result,
73-
data: result.data.map(
119+
data: courses.map(
74120
(course) =>
75121
new CourseResponseDto(course, {
76-
isEnrolled:
77-
userId !== undefined ? enrolledIds.has(course.id) : undefined,
122+
isEnrolled: userId !== undefined ? enrolledIds.has(course.id) : undefined,
123+
enrollmentCount: countMap.get(course.id) ?? 0,
78124
}),
79125
),
126+
total,
127+
page,
128+
limit,
129+
totalPages: Math.ceil(total / limit),
80130
};
81131
}
82132

133+
async getUniqueTags(): Promise<string[]> {
134+
const courses = await this.courseRepository.find({
135+
where: { published: true },
136+
select: ['tags'],
137+
});
138+
const tagSet = new Set<string>();
139+
courses.forEach((c) => (c.tags ?? []).forEach((t) => tagSet.add(t)));
140+
return Array.from(tagSet).sort();
141+
}
142+
83143
async findOne(id: string): Promise<CourseResponseDto> {
84144
const course = await this.courseRepository.findOne({ where: { id } });
85145
if (!course) {
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { IsOptional, IsString, IsIn } from 'class-validator';
2+
import { ApiProperty } from '@nestjs/swagger';
3+
import { PaginationDto } from '../../common/dto/pagination.dto';
4+
5+
export class CourseFilterDto extends PaginationDto {
6+
@ApiProperty({ required: false })
7+
@IsOptional()
8+
@IsString()
9+
search?: string;
10+
11+
@ApiProperty({ required: false })
12+
@IsOptional()
13+
@IsString()
14+
difficulty?: string;
15+
16+
@ApiProperty({ required: false, description: 'Comma-separated tags' })
17+
@IsOptional()
18+
@IsString()
19+
tags?: string;
20+
21+
@ApiProperty({ required: false, enum: ['createdAt', 'title', 'difficulty'] })
22+
@IsOptional()
23+
@IsIn(['createdAt', 'title', 'difficulty'])
24+
sortBy?: 'createdAt' | 'title' | 'difficulty';
25+
26+
@ApiProperty({ required: false, enum: ['asc', 'desc'] })
27+
@IsOptional()
28+
@IsIn(['asc', 'desc'])
29+
sortOrder?: 'asc' | 'desc';
30+
}
Lines changed: 28 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,37 @@
1-
import {
2-
IsString,
3-
IsNotEmpty,
4-
IsBoolean,
5-
IsDate,
6-
IsOptional,
7-
} from 'class-validator';
81
import { ApiProperty } from '@nestjs/swagger';
92

103
export class CourseResponseDto {
11-
@ApiProperty({ example: '123e4567-e89b-12d3-a456-426614174000', description: 'id field' })
12-
@IsString()
13-
@IsNotEmpty()
4+
@ApiProperty()
145
id: string;
156

16-
@ApiProperty({ example: 'Intro to Blockchain', description: 'title field' })
17-
@IsString()
18-
@IsNotEmpty()
7+
@ApiProperty()
198
title: string;
209

21-
@ApiProperty({ example: 'A concise description of the resource.', description: 'description field' })
22-
@IsString()
23-
@IsNotEmpty()
10+
@ApiProperty()
2411
description: string;
2512

26-
@ApiProperty({ example: true, description: 'published field' })
27-
@IsBoolean()
13+
@ApiProperty()
2814
published: boolean;
2915

30-
@ApiProperty({ example: '2026-04-22T00:00:00.000Z', description: 'createdAt field' })
31-
@IsDate()
16+
@ApiProperty({ nullable: true })
17+
difficulty: string | null;
18+
19+
@ApiProperty({ type: [String] })
20+
tags: string[];
21+
22+
@ApiProperty({ nullable: true })
23+
thumbnailUrl: string | null;
24+
25+
@ApiProperty()
26+
enrollmentCount: number;
27+
28+
@ApiProperty()
3229
createdAt: Date;
3330

34-
@ApiProperty({ example: '2026-04-22T00:00:00.000Z', description: 'updatedAt field' })
35-
@IsDate()
31+
@ApiProperty()
3632
updatedAt: Date;
3733

38-
/** Present on GET /courses when the request includes a valid JWT */
39-
@ApiProperty({ example: true, description: 'isEnrolled field', required: false })
40-
@IsOptional()
41-
@IsBoolean()
34+
@ApiProperty({ required: false })
4235
isEnrolled?: boolean;
4336

4437
constructor(
@@ -47,20 +40,27 @@ export class CourseResponseDto {
4740
title: string;
4841
description: string;
4942
published: boolean;
43+
difficulty?: string | null;
44+
tags?: string[];
45+
thumbnailUrl?: string | null;
46+
registrations?: { id: string }[];
5047
createdAt: Date;
5148
updatedAt: Date;
5249
},
53-
options?: { isEnrolled?: boolean },
50+
options?: { isEnrolled?: boolean; enrollmentCount?: number },
5451
) {
5552
this.id = course.id;
5653
this.title = course.title;
5754
this.description = course.description;
5855
this.published = course.published;
56+
this.difficulty = course.difficulty ?? null;
57+
this.tags = course.tags ?? [];
58+
this.thumbnailUrl = course.thumbnailUrl ?? null;
59+
this.enrollmentCount = options?.enrollmentCount ?? course.registrations?.length ?? 0;
5960
this.createdAt = course.createdAt;
6061
this.updatedAt = course.updatedAt;
6162
if (options?.isEnrolled !== undefined) {
6263
this.isEnrolled = options.isEnrolled;
6364
}
6465
}
6566
}
66-
Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,36 @@
1-
import { IsString, IsNotEmpty, IsBoolean, IsOptional } from 'class-validator';
1+
import { IsString, IsNotEmpty, IsBoolean, IsOptional, IsArray, IsUrl } from 'class-validator';
22
import { ApiProperty } from '@nestjs/swagger';
33

44
export class CreateCourseDto {
5-
@ApiProperty({ example: 'Intro to Blockchain', description: 'title field' })
5+
@ApiProperty({ example: 'Intro to Blockchain' })
66
@IsString()
77
@IsNotEmpty()
88
title: string;
99

10-
@ApiProperty({ example: 'A concise description of the resource.', description: 'description field' })
10+
@ApiProperty({ example: 'A concise description of the resource.' })
1111
@IsString()
1212
@IsNotEmpty()
1313
description: string;
1414

15-
@ApiProperty({ example: true, description: 'published field', required: false })
15+
@ApiProperty({ example: true, required: false })
1616
@IsBoolean()
1717
@IsOptional()
1818
published?: boolean;
19+
20+
@ApiProperty({ example: 'Beginner', required: false })
21+
@IsString()
22+
@IsOptional()
23+
difficulty?: string;
24+
25+
@ApiProperty({ example: ['bitcoin', 'defi'], required: false, type: [String] })
26+
@IsArray()
27+
@IsString({ each: true })
28+
@IsOptional()
29+
tags?: string[];
30+
31+
@ApiProperty({ example: 'https://example.com/thumb.jpg', required: false })
32+
@IsUrl()
33+
@IsOptional()
34+
thumbnailUrl?: string;
1935
}
2036

backend/src/courses/entities/course-registration.entity.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
ManyToOne,
66
CreateDateColumn,
77
JoinColumn,
8+
Unique,
89
} from 'typeorm';
910
import { User } from '../../users/entities/user.entity';
1011
import { Course } from './course.entity';

backend/src/courses/entities/course.entity.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,15 @@ export class Course {
2424
@Column({ default: false })
2525
published: boolean;
2626

27+
@Column({ nullable: true })
28+
difficulty: string | null;
29+
30+
@Column({ type: 'simple-json', default: '[]' })
31+
tags: string[];
32+
33+
@Column({ nullable: true })
34+
thumbnailUrl: string | null;
35+
2736
@OneToMany(() => CourseRegistration, (registration) => registration.course)
2837
registrations: CourseRegistration[];
2938

@@ -39,3 +48,4 @@ export class Course {
3948
// Soft-delete field for admin restore functionality
4049
@DeleteDateColumn({ nullable: true })
4150
deletedAt: Date | null;
51+
}

0 commit comments

Comments
 (0)