Skip to content

Commit 398131f

Browse files
committed
feat: Course moderation, replace mock statistic with live
1 parent e744825 commit 398131f

19 files changed

Lines changed: 614 additions & 53 deletions

apps/api/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@
1717
"test:watch": "jest --watch",
1818
"test:cov": "jest --coverage",
1919
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
20-
"test:e2e": "jest --config ./test/jest-e2e.json"
20+
"test:e2e": "jest --config ./test/jest-e2e.json",
21+
"seed:admin": "ts-node -r tsconfig-paths/register src/scripts/create-admin.ts"
2122
},
2223
"dependencies": {
2324
"@google/genai": "^1.31.0",

apps/api/src/admin/admin.module.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
import { Module } from '@nestjs/common';
2+
import { TypeOrmModule } from '@nestjs/typeorm';
23

34
import { AdminController } from './admin.controller';
45
import { AdminService } from './admin.service';
6+
import { CoursesModule } from '../courses/courses.module';
7+
import { User } from '../users/entities/user.entity';
58
import { UsersModule } from '../users/users.module';
69

710
@Module({
8-
imports: [UsersModule],
11+
imports: [TypeOrmModule.forFeature([User]), UsersModule, CoursesModule],
912
controllers: [AdminController],
1013
providers: [AdminService],
1114
})

apps/api/src/admin/admin.service.spec.ts

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@ import { Test, type TestingModule } from '@nestjs/testing';
22

33
import { AdminService } from './admin.service';
44
import { UsersService } from '../users/users.service';
5+
import { CoursesService } from '../courses/courses.service';
56

67
describe('AdminService', () => {
78
let service: AdminService;
89
let usersService: jest.Mocked<UsersService>;
10+
let coursesService: jest.Mocked<CoursesService>;
911

1012
const mockUsersService: Partial<jest.Mocked<UsersService>> = {
1113
count: jest.fn(),
@@ -14,16 +16,23 @@ describe('AdminService', () => {
1416
updateStatus: jest.fn(),
1517
};
1618

19+
const mockCoursesService: Partial<jest.Mocked<CoursesService>> = {
20+
count: jest.fn(),
21+
countEnrollments: jest.fn(),
22+
};
23+
1724
beforeEach(async () => {
1825
const module: TestingModule = await Test.createTestingModule({
1926
providers: [
2027
AdminService,
2128
{ provide: UsersService, useValue: mockUsersService },
29+
{ provide: CoursesService, useValue: mockCoursesService },
2230
],
2331
}).compile();
2432

2533
service = module.get<AdminService>(AdminService);
2634
usersService = module.get(UsersService);
35+
coursesService = module.get(CoursesService);
2736
});
2837

2938
afterEach(() => {
@@ -35,26 +44,40 @@ describe('AdminService', () => {
3544
});
3645

3746
describe('getSystemStats', () => {
38-
it('should return system statistics with total users count', async () => {
39-
const expectedCount = 42;
40-
usersService.count.mockResolvedValue(expectedCount);
47+
it('should return system statistics with counts', async () => {
48+
const userCount = 42;
49+
const courseCount = 10;
50+
const enrollmentCount = 150;
51+
52+
usersService.count.mockResolvedValue(userCount);
53+
coursesService.count.mockResolvedValue(courseCount);
54+
coursesService.countEnrollments.mockResolvedValue(enrollmentCount);
4155

4256
const result = await service.getSystemStats();
4357

4458
expect(usersService.count).toHaveBeenCalledTimes(1);
59+
expect(coursesService.count).toHaveBeenCalledTimes(1);
60+
expect(coursesService.countEnrollments).toHaveBeenCalledTimes(1);
61+
4562
expect(result).toEqual({
46-
totalUsers: expectedCount,
47-
totalCourses: 0,
48-
totalEnrollments: 0,
63+
totalUsers: userCount,
64+
totalCourses: courseCount,
65+
totalEnrollments: enrollmentCount,
4966
});
5067
});
5168

52-
it('should return zero users when no users exist', async () => {
69+
it('should return zero counts when no data exists', async () => {
5370
usersService.count.mockResolvedValue(0);
71+
coursesService.count.mockResolvedValue(0);
72+
coursesService.countEnrollments.mockResolvedValue(0);
5473

5574
const result = await service.getSystemStats();
5675

57-
expect(result.totalUsers).toBe(0);
76+
expect(result).toEqual({
77+
totalUsers: 0,
78+
totalCourses: 0,
79+
totalEnrollments: 0,
80+
});
5881
});
5982
});
6083
});
Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Injectable } from '@nestjs/common';
22

3+
import { CoursesService } from '../courses/courses.service';
34
import { UsersService } from '../users/users.service';
45

56
export interface SystemStats {
@@ -10,15 +11,20 @@ export interface SystemStats {
1011

1112
@Injectable()
1213
export class AdminService {
13-
constructor(private usersService: UsersService) {}
14+
constructor(
15+
private usersService: UsersService,
16+
private coursesService: CoursesService,
17+
) {}
1418

1519
async getSystemStats(): Promise<SystemStats> {
1620
const totalUsers = await this.usersService.count();
21+
const totalCourses = await this.coursesService.count();
22+
const totalEnrollments = await this.coursesService.countEnrollments();
1723

1824
return {
1925
totalUsers,
20-
totalCourses: 0, // Will be implemented when courses module is created
21-
totalEnrollments: 0, // Will be implemented when enrollments module is created
26+
totalCourses,
27+
totalEnrollments,
2228
};
2329
}
2430
}

apps/api/src/courses/courses.controller.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,4 +268,31 @@ export class CoursesController {
268268
): Promise<void> {
269269
return this.coursesService.removeLesson(lessonId, user.id);
270270
}
271+
@Patch(':id/submit')
272+
@UseGuards(JwtAuthGuard, RolesGuard)
273+
@Roles(UserRole.INSTRUCTOR)
274+
submit(@Param('id') id: string, @CurrentUser() user: User): Promise<Course> {
275+
return this.coursesService.submitForApproval(id, user.id);
276+
}
277+
278+
@Patch(':id/approve')
279+
@UseGuards(JwtAuthGuard, RolesGuard)
280+
@Roles(UserRole.ADMIN)
281+
approve(@Param('id') id: string): Promise<Course> {
282+
return this.coursesService.approveCourse(id);
283+
}
284+
285+
@Patch(':id/reject')
286+
@UseGuards(JwtAuthGuard, RolesGuard)
287+
@Roles(UserRole.ADMIN)
288+
reject(@Param('id') id: string): Promise<Course> {
289+
return this.coursesService.rejectCourse(id);
290+
}
291+
292+
@Get('admin/pending')
293+
@UseGuards(JwtAuthGuard, RolesGuard)
294+
@Roles(UserRole.ADMIN)
295+
findPending(): Promise<Course[]> {
296+
return this.coursesService.findAllPending();
297+
}
271298
}

apps/api/src/courses/courses.service.ts

Lines changed: 60 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { CourseSection } from './entities/course-section.entity';
1818
import { Course } from './entities/course.entity';
1919
import { Enrollment } from './entities/enrollment.entity';
2020
import { Lesson } from './entities/lesson.entity';
21+
import { CourseStatus } from './enums/course-status.enum';
2122
import { User } from '../users/entities/user.entity';
2223
import { UserRole } from '../users/enums/user-role.enum';
2324

@@ -97,7 +98,7 @@ export class CoursesService {
9798

9899
// 1. UPDATED: Multi-field Search (Title, Description, Tags)
99100
if (search) {
100-
const searchLower = `%${search.toLowerCase()}%`;
101+
const searchLower = `% ${search.toLowerCase()}% `;
101102
queryBuilder.andWhere(
102103
new Brackets((qb) => {
103104
qb.where('LOWER(course.title) LIKE :search', { search: searchLower })
@@ -121,9 +122,9 @@ export class CoursesService {
121122
queryBuilder.andWhere(
122123
new Brackets((qb) => {
123124
tags.forEach((tag, index) => {
124-
const paramName = `tag_${index}`;
125-
qb.orWhere(`course.tags LIKE :${paramName}`, {
126-
[paramName]: `%${tag}%`,
125+
const paramName = `tag_${index} `;
126+
qb.orWhere(`course.tags LIKE:${paramName} `, {
127+
[paramName]: `% ${tag}% `,
127128
});
128129
});
129130
}),
@@ -442,7 +443,7 @@ export class CoursesService {
442443
.createQueryBuilder('course')
443444
.leftJoinAndSelect('course.instructor', 'instructor')
444445
.loadRelationCountAndMap('course.studentCount', 'course.enrollments')
445-
.where('course.isPublished = :isPublished', { isPublished: true })
446+
.where('course.status = :status', { status: CourseStatus.PUBLISHED })
446447
.andWhere('course.id NOT IN (:...enrolledIds)', {
447448
enrolledIds: Array.from(enrolledCourseIds),
448449
})
@@ -462,26 +463,26 @@ export class CoursesService {
462463
.createQueryBuilder('course')
463464
.leftJoinAndSelect('course.instructor', 'instructor')
464465
.loadRelationCountAndMap('course.studentCount', 'course.enrollments')
465-
.where('course.isPublished = :isPublished', { isPublished: true })
466+
.where('course.status = :status', { status: CourseStatus.PUBLISHED })
466467
.andWhere('course.id NOT IN (:...enrolledIds)', {
467468
enrolledIds: Array.from(enrolledCourseIds),
468469
});
469470

470471
// Add tag matching conditions (OR for any tag match)
471472
const tagConditions = Array.from(userTags).map(
472-
(_, index) => `LOWER(course.tags) LIKE :tag_${index}`,
473+
(_, index) => `LOWER(course.tags) LIKE:tag_${index} `,
473474
);
474475
if (tagConditions.length > 0) {
475476
queryBuilder.andWhere(
476477
new Brackets((qb) => {
477478
Array.from(userTags).forEach((tag, index) => {
478479
if (index === 0) {
479-
qb.where(`LOWER(course.tags) LIKE :tag_${index}`, {
480-
[`tag_${index}`]: `%${tag}%`,
480+
qb.where(`LOWER(course.tags) LIKE:tag_${index} `, {
481+
[`tag_${index} `]: ` % ${tag}% `,
481482
});
482483
} else {
483-
qb.orWhere(`LOWER(course.tags) LIKE :tag_${index}`, {
484-
[`tag_${index}`]: `%${tag}%`,
484+
qb.orWhere(`LOWER(course.tags) LIKE:tag_${index} `, {
485+
[`tag_${index} `]: ` % ${tag}% `,
485486
});
486487
}
487488
});
@@ -516,8 +517,9 @@ export class CoursesService {
516517
): Promise<Course> {
517518
const course = this.coursesRepository.create({
518519
...createCourseDto,
519-
instructorId,
520-
isPublished: false,
520+
instructor: { id: instructorId },
521+
status: CourseStatus.DRAFT, // Default to draft
522+
isPublished: false, // Legacy field
521523
});
522524
return this.coursesRepository.save(course);
523525
}
@@ -665,4 +667,49 @@ export class CoursesService {
665667

666668
await this.lessonRepository.remove(lesson);
667669
}
670+
671+
// --- Statistics ---
672+
673+
async count(): Promise<number> {
674+
return this.coursesRepository.count();
675+
}
676+
677+
async countEnrollments(): Promise<number> {
678+
return this.enrollmentRepository.count();
679+
}
680+
681+
// --- Moderation ---
682+
683+
async submitForApproval(id: string, userId: string): Promise<Course> {
684+
const course = await this.findOne(id, { id: userId });
685+
if (course.instructorId !== userId) {
686+
throw new ForbiddenException(
687+
'Only the instructor can submit this course',
688+
);
689+
}
690+
course.status = CourseStatus.PENDING;
691+
return this.coursesRepository.save(course);
692+
}
693+
694+
async approveCourse(id: string): Promise<Course> {
695+
const course = await this.findOne(id, { role: UserRole.ADMIN });
696+
course.status = CourseStatus.PUBLISHED;
697+
course.isPublished = true;
698+
return this.coursesRepository.save(course);
699+
}
700+
701+
async rejectCourse(id: string): Promise<Course> {
702+
const course = await this.findOne(id, { role: UserRole.ADMIN });
703+
course.status = CourseStatus.REJECTED;
704+
course.isPublished = false;
705+
return this.coursesRepository.save(course);
706+
}
707+
708+
async findAllPending(): Promise<Course[]> {
709+
return this.coursesRepository.find({
710+
where: { status: CourseStatus.PENDING },
711+
relations: ['instructor'],
712+
order: { createdAt: 'DESC' },
713+
});
714+
}
668715
}

apps/api/src/courses/entities/course.entity.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import {
99
JoinColumn,
1010
} from 'typeorm';
1111

12+
import { CourseStatus } from '../enums/course-status.enum';
13+
1214
import type { CourseSection } from './course-section.entity';
1315
import type { Enrollment } from './enrollment.entity';
1416
import type { User } from '../../users/entities/user.entity';
@@ -43,6 +45,13 @@ export class Course {
4345
})
4446
level: CourseLevel;
4547

48+
@Column({
49+
type: 'enum',
50+
enum: CourseStatus,
51+
default: CourseStatus.DRAFT,
52+
})
53+
status: CourseStatus;
54+
4655
@Column({ default: false })
4756
isPublished: boolean;
4857

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export enum CourseStatus {
2+
DRAFT = 'draft',
3+
PENDING = 'pending',
4+
PUBLISHED = 'published',
5+
REJECTED = 'rejected',
6+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { NestFactory } from '@nestjs/core';
2+
3+
import { AppModule } from '../app.module';
4+
import { UserRole } from '../users/enums/user-role.enum';
5+
import { UsersService } from '../users/users.service';
6+
7+
async function bootstrap(): Promise<void> {
8+
const app = await NestFactory.createApplicationContext(AppModule);
9+
const usersService = app.get(UsersService);
10+
11+
const email = 'admin@example.com';
12+
const password = 'Password123!';
13+
const fullName = 'Admin User';
14+
15+
/* eslint-disable no-console */
16+
console.log(`Checking for user with email: ${email}`);
17+
18+
let user = await usersService.findByEmail(email);
19+
20+
if (!user) {
21+
console.log('User not found. Creating new admin user...');
22+
23+
try {
24+
const result = await usersService.createWithActivationToken({
25+
email,
26+
fullName,
27+
password,
28+
});
29+
30+
// eslint-disable-next-line prefer-destructuring
31+
user = result.user;
32+
console.log(`User created with ID: ${user.id}`);
33+
} catch (error) {
34+
console.error('Error creating user:', error);
35+
process.exit(1);
36+
}
37+
} else {
38+
console.log(`User found with ID: ${user.id}. Updating to admin...`);
39+
}
40+
41+
await usersService.updateRole(user.id, UserRole.ADMIN);
42+
console.log('Role updated to ADMIN');
43+
44+
await usersService.activateUser(user.id);
45+
console.log('User activated');
46+
47+
console.log('Admin user setup complete.');
48+
console.log(`Email: ${email}`);
49+
console.log(`Password: ${password}`);
50+
51+
await app.close();
52+
/* eslint-enable no-console */
53+
}
54+
55+
void bootstrap();

0 commit comments

Comments
 (0)