Skip to content

Commit 306f04a

Browse files
committed
feat: add ScrollToTop component to ensure users start at the top on route changes
- Display login modal when accessing protected routes unauthenticated - Fix linting issues in various files
1 parent 97bc731 commit 306f04a

31 files changed

Lines changed: 986 additions & 731 deletions

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
Online learning platform built with React, NestJS, and Turborepo.
44

55
## App URL
6+
67
- Github Repo: https://github.com/PhuocHoan/Learnix
78
- Vercel: https://learnix-teal.vercel.app
89

@@ -24,6 +25,7 @@ pnpm dev # Start dev servers (web:5173, api:3000)
2425
```
2526

2627
## Seed Course Data
28+
2729
```bash
2830
cd apps/api && pnpm ts-node src/seed.ts
2931
```

apps/api/eslint.config.mjs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,4 +319,14 @@ export default tseslint.config(
319319
'no-console': 'off', // Seed scripts use console for CLI output
320320
},
321321
},
322+
323+
// Upload module files - filesystem access with validated paths
324+
// The security plugin cannot understand runtime path validation,
325+
// but these files implement proper path traversal prevention
326+
{
327+
files: ['**/upload/*.ts'],
328+
rules: {
329+
'security/detect-non-literal-fs-filename': 'off',
330+
},
331+
},
322332
);

apps/api/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
"test:e2e": "jest --config ./test/jest-e2e.json"
2121
},
2222
"dependencies": {
23-
"@google/genai": "^1.30.0",
23+
"@google/genai": "^1.31.0",
2424
"@nestjs/common": "^11.1.9",
2525
"@nestjs/config": "^4.0.2",
2626
"@nestjs/core": "^11.1.9",
@@ -42,7 +42,7 @@
4242
"pg": "^8.16.3",
4343
"reflect-metadata": "^0.2.2",
4444
"rxjs": "^7.8.2",
45-
"typeorm": "^0.3.27"
45+
"typeorm": "^0.3.28"
4646
},
4747
"devDependencies": {
4848
"@eslint/compat": "^2.0.0",

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

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
/* eslint-disable @typescript-eslint/no-misused-promises -- Jest handles async test functions */
2-
/* eslint-disable @typescript-eslint/no-unnecessary-condition -- Test assertions need optional chains */
31
import { UnauthorizedException, BadRequestException } from '@nestjs/common';
42
import { JwtService } from '@nestjs/jwt';
53
import { Test, type TestingModule } from '@nestjs/testing';
@@ -16,7 +14,14 @@ import { UsersService } from '../users/users.service';
1614

1715
// Mock bcrypt
1816
jest.mock('bcrypt');
19-
const mockedBcrypt = bcrypt as jest.Mocked<typeof bcrypt>;
17+
const mockedBcrypt = {
18+
compare: bcrypt.compare as jest.MockedFunction<
19+
(data: string | Buffer, encrypted: string) => Promise<boolean>
20+
>,
21+
hash: bcrypt.hash as jest.MockedFunction<
22+
(data: string | Buffer, saltOrRounds: string | number) => Promise<string>
23+
>,
24+
};
2025

2126
describe('AuthService', () => {
2227
let service: AuthService;
@@ -119,13 +124,13 @@ describe('AuthService', () => {
119124
describe('validateUser', () => {
120125
it('should return user without password when credentials are valid', async () => {
121126
usersService.findByEmail.mockResolvedValue(mockUser as User);
122-
mockedBcrypt.compare.mockImplementation(() => Promise.resolve(true));
127+
mockedBcrypt.compare.mockResolvedValue(true);
123128

124129
const result = await service.validateUser('test@example.com', 'password');
125130

126131
expect(result).toBeDefined();
127132
expect(result?.email).toBe('test@example.com');
128-
expect((result as Record<string, unknown>)?.password).toBeUndefined();
133+
expect((result as Record<string, unknown>).password).toBeUndefined();
129134
});
130135

131136
it('should return null when user does not exist', async () => {
@@ -152,7 +157,7 @@ describe('AuthService', () => {
152157

153158
it('should return null when password is incorrect', async () => {
154159
usersService.findByEmail.mockResolvedValue(mockUser as User);
155-
mockedBcrypt.compare.mockImplementation(() => Promise.resolve(false));
160+
(mockedBcrypt.compare as jest.Mock).mockResolvedValue(false);
156161

157162
const result = await service.validateUser(
158163
'test@example.com',
@@ -166,7 +171,7 @@ describe('AuthService', () => {
166171
describe('login', () => {
167172
it('should return access token and user for valid credentials', async () => {
168173
usersService.findByEmail.mockResolvedValue(mockUser as User);
169-
mockedBcrypt.compare.mockImplementation(() => Promise.resolve(true));
174+
mockedBcrypt.compare.mockResolvedValue(true);
170175

171176
const result = await service.login({
172177
email: 'test@example.com',
@@ -192,7 +197,7 @@ describe('AuthService', () => {
192197

193198
it('should throw UnauthorizedException for unverified email', async () => {
194199
usersService.findByEmail.mockResolvedValue(mockUnverifiedUser as User);
195-
mockedBcrypt.compare.mockImplementation(() => Promise.resolve(true));
200+
mockedBcrypt.compare.mockResolvedValue(true);
196201

197202
await expect(
198203
service.login({
@@ -589,7 +594,7 @@ describe('AuthService', () => {
589594
usersService.hasPassword.mockResolvedValue(true);
590595
usersService.findOne.mockResolvedValue(mockUser as User);
591596
usersService.findByEmail.mockResolvedValue(mockUser as User);
592-
mockedBcrypt.compare.mockImplementation(() => Promise.resolve(true));
597+
mockedBcrypt.compare.mockResolvedValue(true);
593598

594599
const result = await service.deleteAccount(
595600
'user-1',
@@ -622,7 +627,7 @@ describe('AuthService', () => {
622627
usersService.hasPassword.mockResolvedValue(true);
623628
usersService.findOne.mockResolvedValue(mockUser as User);
624629
usersService.findByEmail.mockResolvedValue(mockUser as User);
625-
mockedBcrypt.compare.mockImplementation(() => Promise.resolve(false));
630+
mockedBcrypt.compare.mockResolvedValue(false);
626631

627632
await expect(
628633
service.deleteAccount('user-1', 'wrongpassword', 'DELETE'),

apps/api/src/common/guards/roles.guard.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,11 @@ export class RolesGuard implements CanActivate {
1818
constructor(private reflector: Reflector) {}
1919

2020
canActivate(context: ExecutionContext): boolean {
21-
const requiredRoles = this.reflector.getAllAndOverride<UserRole[]>(
22-
ROLES_KEY,
23-
[context.getHandler(), context.getClass()],
24-
);
21+
const requiredRoles = this.reflector.getAllAndOverride<
22+
UserRole[] | undefined
23+
>(ROLES_KEY, [context.getHandler(), context.getClass()]);
2524

26-
// No roles required for this route - TypeScript may infer as always defined but runtime differs
27-
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
25+
// No roles required for this route
2826
if (!requiredRoles) {
2927
return true;
3028
}

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

Lines changed: 3 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -148,8 +148,6 @@ export class CoursesService {
148148

149149
const uniqueTags = new Set<string>();
150150
courses.forEach((course) => {
151-
// Tags column is nullable in DB - can be null at runtime
152-
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
153151
course.tags?.forEach((tag) => uniqueTags.add(tag));
154152
});
155153

@@ -216,10 +214,7 @@ export class CoursesService {
216214
}
217215

218216
// Initialize array if null (legacy data safety - DB column is nullable)
219-
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
220-
if (!enrollment.completedLessonIds) {
221-
enrollment.completedLessonIds = [];
222-
}
217+
enrollment.completedLessonIds ??= [];
223218

224219
// Add lesson ID if not already present
225220
if (!enrollment.completedLessonIds.includes(lessonId)) {
@@ -295,12 +290,9 @@ export class CoursesService {
295290
});
296291

297292
const result: EnrolledCourseDto[] = enrollments.map((enrollment) => {
298-
/* eslint-disable @typescript-eslint/no-unnecessary-condition -- DB relations may not be loaded */
299-
const allLessons =
300-
enrollment.course.sections?.flatMap((s) => s.lessons) ?? [];
301-
/* eslint-enable @typescript-eslint/no-unnecessary-condition */
293+
const allLessons = enrollment.course.sections.flatMap((s) => s.lessons);
302294
const totalLessons = allLessons.length;
303-
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- completedLessonIds is nullable in DB
295+
304296
const completedLessons = enrollment.completedLessonIds?.length ?? 0;
305297
const progress =
306298
totalLessons > 0
@@ -316,13 +308,11 @@ export class CoursesService {
316308
thumbnailUrl: enrollment.course.thumbnailUrl,
317309
level: enrollment.course.level,
318310

319-
/* eslint-disable @typescript-eslint/no-unnecessary-condition -- instructor relation may not be loaded */
320311
instructor: {
321312
id: enrollment.course.instructor?.id ?? '',
322313
fullName:
323314
enrollment.course.instructor?.fullName ?? 'Unknown Instructor',
324315
},
325-
/* eslint-enable @typescript-eslint/no-unnecessary-condition */
326316
progress,
327317
totalLessons,
328318
completedLessons,
@@ -420,7 +410,6 @@ export class CoursesService {
420410
const enrolledCourseIds = new Set(enrollments.map((e) => e.courseId));
421411
const userTags = new Set<string>();
422412
enrollments.forEach((enrollment) => {
423-
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- tags is nullable in DB
424413
enrollment.course.tags?.forEach((tag) => userTags.add(tag.toLowerCase()));
425414
});
426415

@@ -481,7 +470,6 @@ export class CoursesService {
481470

482471
// Step 4: Score courses by number of matching tags
483472
const scoredCourses = candidateCourses.map((course) => {
484-
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- tags is nullable in DB
485473
const courseTags = course.tags?.map((t) => t.toLowerCase()) ?? [];
486474
const matchingTags = courseTags.filter((tag) => userTags.has(tag));
487475
return {

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,15 +47,15 @@ export class Course {
4747
isPublished: boolean;
4848

4949
@Column({ type: 'simple-array', nullable: true })
50-
tags: string[]; // e.g., ["react", "frontend", "web"]
50+
tags: string[] | null; // e.g., ["react", "frontend", "web"]
5151

5252
// Instructor relationship
5353
@Column({ name: 'instructor_id' })
5454
instructorId: string;
5555

5656
@ManyToOne('User')
5757
@JoinColumn({ name: 'instructor_id' })
58-
instructor: User;
58+
instructor: User | null;
5959

6060
// Content relationship
6161
@OneToMany('CourseSection', (section: CourseSection) => section.course, {

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ export class Enrollment {
3333
course: Course;
3434

3535
@Column({ type: 'simple-array', nullable: true })
36-
completedLessonIds: string[]; // List of IDs of lessons completed
36+
completedLessonIds: string[] | null; // List of IDs of lessons completed
3737

3838
@Column({ nullable: true })
3939
completedAt: Date; // When they finished the course

apps/api/src/dashboard/dashboard.service.ts

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -114,19 +114,15 @@ export class DashboardService {
114114
let totalDurationSeconds = 0;
115115

116116
for (const enrollment of enrollments) {
117-
/* eslint-disable @typescript-eslint/no-unnecessary-condition -- completedLessonIds is nullable in DB */
118-
if (
119-
!enrollment.completedLessonIds ||
120-
enrollment.completedLessonIds.length === 0
121-
) {
122-
/* eslint-enable @typescript-eslint/no-unnecessary-condition */
117+
const { completedLessonIds } = enrollment;
118+
if (!completedLessonIds || completedLessonIds.length === 0) {
123119
continue;
124120
}
125121

126122
const allLessons = enrollment.course.sections.flatMap((s) => s.lessons);
127123

128124
const completedLessons = allLessons.filter((l) =>
129-
enrollment.completedLessonIds.includes(l.id),
125+
completedLessonIds.includes(l.id),
130126
);
131127

132128
totalDurationSeconds += completedLessons.reduce(
@@ -152,7 +148,7 @@ export class DashboardService {
152148
const currentCourses = enrollments.map((enrollment) => {
153149
const allLessons = enrollment.course.sections.flatMap((s) => s.lessons);
154150
const totalLessons = allLessons.length;
155-
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- completedLessonIds is nullable in DB
151+
156152
const completedCount = enrollment.completedLessonIds?.length ?? 0;
157153

158154
const progress =

apps/api/src/quizzes/services/ai-quiz-generator.service.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ export interface GeneratedQuestion {
1212

1313
@Injectable()
1414
export class AiQuizGeneratorService {
15-
private genAI: GoogleGenAI;
15+
private genAI: GoogleGenAI | null = null;
1616

1717
constructor(private configService: ConfigService) {
1818
const apiKey = this.configService.get<string>('GEMINI_API_KEY');
@@ -25,8 +25,6 @@ export class AiQuizGeneratorService {
2525
lessonText: string,
2626
numberOfQuestions: number = 5,
2727
): Promise<GeneratedQuestion[]> {
28-
// Runtime safety: genAI is null when API key is not configured
29-
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
3028
if (!this.genAI) {
3129
throw new Error('Gemini API key not configured');
3230
}

0 commit comments

Comments
 (0)