diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 6735583..7cf8fff 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,23 +1,27 @@ +# 📌 Pull Request Title + ## Description - + -## Type of Change +## Related Issues -- [ ] Bug fix -- [ ] New feature -- [ ] Breaking change -- [ ] Documentation update + -## Checklist +## Changes Made + +- [ ] List key changes made in this PR. + +## How to Test -- [ ] Lint passes (`npm run lint` in both backend and frontend) -- [ ] Build passes (`npm run build` in both backend and frontend) -- [ ] Tests pass (`npm run test` in backend) -- [ ] E2E tests pass (`npm run test:e2e` in backend) -- [ ] No new TypeScript errors -- [ ] No console errors or warnings + -## Related Issue +## Screenshots (if applicable) + + + +## Checklist -Closes # \ No newline at end of file +- [ ] My code follows the project's coding style. +- [ ] I have tested these changes locally. +- [ ] Documentation has been updated where necessary. diff --git a/.github/workflows/PULL_REQUEST_TEMPLATE.md b/.github/workflows/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..0764e0f --- /dev/null +++ b/.github/workflows/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,27 @@ +# 📌 Pull Request Title + +## Description + + + +## Related Issues + + + +## Changes Made + +- [ ] List key changes made in this PR. + +## How to Test + + + +## Screenshots (if applicable) + + + +## Checklist + +- [ ] My code follows the project's coding style. +- [ ] I have tested these changes locally. +- [ ] Documentation has been updated where necessary. \ No newline at end of file diff --git a/backend/lint_output.txt b/backend/lint_output.txt new file mode 100644 index 0000000..f007a0e Binary files /dev/null and b/backend/lint_output.txt differ diff --git a/backend/src/admin/admin-courses.controller.ts b/backend/src/admin/admin-courses.controller.ts index ca7fcb5..c2a1914 100644 --- a/backend/src/admin/admin-courses.controller.ts +++ b/backend/src/admin/admin-courses.controller.ts @@ -27,6 +27,7 @@ import { UserRole } from '../users/entities/user.entity'; import { CreateCourseDto } from '../courses/dto/create-course.dto'; import { UpdateCourseDto } from '../courses/dto/update-course.dto'; import { CourseResponseDto } from '../courses/dto/course-response.dto'; +import { LessonResponseDto } from '../lessons/dto/lesson-response.dto'; import { ReorderLessonsDto } from './dto/reorder-lessons.dto'; import { PaginatedResult } from '../common/services/pagination.service'; @@ -143,4 +144,43 @@ export class AdminCoursesController { async unpublish(@Param('id') id: string): Promise { return this.coursesService.unpublishCourse(id); } + + @Get(':id/lessons') + @ApiOperation({ summary: 'List all lessons for a course (admin)' }) + async findAllLessons( + @Param('id') courseId: string, + @Query('page') page = 1, + @Query('limit') limit = 10, + ): Promise> { + const result = await this.lessonsService.findAllByCoursePaginated( + courseId, + Number(page), + Number(limit), + false, // Fetch all regardless of published status + ); + return { + ...result, + data: result.data.map((lesson) => new LessonResponseDto(lesson)), + }; + } + + @Patch(':id/lessons/:lessonId/publish') + @ApiOperation({ summary: 'Publish a lesson (admin)' }) + async publishLesson( + @Param('id') courseId: string, + @Param('lessonId') lessonId: string, + ): Promise { + const lesson = await this.lessonsService.setPublished(lessonId, true); + return new LessonResponseDto(lesson); + } + + @Patch(':id/lessons/:lessonId/unpublish') + @ApiOperation({ summary: 'Unpublish a lesson (admin)' }) + async unpublishLesson( + @Param('id') courseId: string, + @Param('lessonId') lessonId: string, + ): Promise { + const lesson = await this.lessonsService.setPublished(lessonId, false); + return new LessonResponseDto(lesson); + } } diff --git a/backend/src/admin/admin-dao.controller.ts b/backend/src/admin/admin-dao.controller.ts index 902df73..a81be26 100644 --- a/backend/src/admin/admin-dao.controller.ts +++ b/backend/src/admin/admin-dao.controller.ts @@ -8,7 +8,6 @@ import { Query, Request, ParseIntPipe, - NotFoundException, BadRequestException, } from '@nestjs/common'; import { diff --git a/backend/src/analytics/analytics.controller.ts b/backend/src/analytics/analytics.controller.ts index 87fad8f..6e8045f 100644 --- a/backend/src/analytics/analytics.controller.ts +++ b/backend/src/analytics/analytics.controller.ts @@ -6,10 +6,6 @@ import { ApiResponse, ApiBearerAuth, } from '@nestjs/swagger'; -import { Roles } from '../common/decorators/roles.decorator'; -import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; -import { RolesGuard } from '../common/guards/roles.guard'; -import { UserRole } from '../common/enums/user-role.enum'; import { AnalyticsService } from './analytics.service'; import { AnalyticsOverviewDto, @@ -17,6 +13,10 @@ import { LearnerActivityPointDto, TopLearnerDto, } from './dto/analytics-response.dto'; +import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; +import { RolesGuard } from '../common/guards/roles.guard'; +import { Roles } from '../common/decorators/roles.decorator'; +import { UserRole } from '../common/enums/user-role.enum'; @ApiTags('Analytics') @ApiBearerAuth('access-token') diff --git a/backend/src/auth/dto/create-auth.dto.ts b/backend/src/auth/dto/create-auth.dto.ts index a15de30..00ef00f 100644 --- a/backend/src/auth/dto/create-auth.dto.ts +++ b/backend/src/auth/dto/create-auth.dto.ts @@ -1,2 +1 @@ -import { ApiProperty } from '@nestjs/swagger'; export class CreateAuthDto {} diff --git a/backend/src/auth/dto/update-auth.dto.ts b/backend/src/auth/dto/update-auth.dto.ts index 8415eae..100de4f 100644 --- a/backend/src/auth/dto/update-auth.dto.ts +++ b/backend/src/auth/dto/update-auth.dto.ts @@ -1,5 +1,4 @@ import { PartialType } from '@nestjs/mapped-types'; import { CreateAuthDto } from './create-auth.dto'; -import { ApiProperty } from '@nestjs/swagger'; export class UpdateAuthDto extends PartialType(CreateAuthDto) {} diff --git a/backend/src/auth/strategies/jwt.strategy.ts b/backend/src/auth/strategies/jwt.strategy.ts index 78ed9b9..ee6d809 100644 --- a/backend/src/auth/strategies/jwt.strategy.ts +++ b/backend/src/auth/strategies/jwt.strategy.ts @@ -18,7 +18,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) { }); } - async validate(payload: any) { + async validate(payload: { sub: string; email: string; role: string }) { const user = await this.userService.findById(payload.sub); if (!user) { diff --git a/backend/src/certificates/certificates.service.spec.ts b/backend/src/certificates/certificates.service.spec.ts index 1747b0e..2281a54 100644 --- a/backend/src/certificates/certificates.service.spec.ts +++ b/backend/src/certificates/certificates.service.spec.ts @@ -54,7 +54,6 @@ const makeUserRepo = () => ({ const makeCourseRepo = () => ({ findOneBy: jest.fn(), }); - describe('CertificateService', () => { let service: CertificateService; let certRepo: ReturnType; diff --git a/backend/src/certificates/certificates.service.ts b/backend/src/certificates/certificates.service.ts index a3719bf..7c6e175 100644 --- a/backend/src/certificates/certificates.service.ts +++ b/backend/src/certificates/certificates.service.ts @@ -326,18 +326,12 @@ export class CertificateService { certificateData, } = issueCertificateDto; - const hashPayload = { - recipientName, - recipientEmail, - courseOrProgram, - issuedAt, - timestamp: Date.now(), - }; + const issuedAtDate = new Date(issuedAt); const certificateHash = this.generateCertificateHash( recipientName + recipientEmail, courseOrProgram, - new Date(issuedAt), + issuedAtDate, ); const existing = await this.certificateRepository.findOne({ diff --git a/backend/src/certificates/dto/create-certificate.dto.ts b/backend/src/certificates/dto/create-certificate.dto.ts index 6acc62d..df7f323 100644 --- a/backend/src/certificates/dto/create-certificate.dto.ts +++ b/backend/src/certificates/dto/create-certificate.dto.ts @@ -1,2 +1 @@ -import { ApiProperty } from '@nestjs/swagger'; export class CreateCertificateDto {} diff --git a/backend/src/certificates/dto/update-certificate.dto.ts b/backend/src/certificates/dto/update-certificate.dto.ts index 2223238..bfd38a8 100644 --- a/backend/src/certificates/dto/update-certificate.dto.ts +++ b/backend/src/certificates/dto/update-certificate.dto.ts @@ -1,5 +1,4 @@ import { PartialType } from '@nestjs/mapped-types'; import { CreateCertificateDto } from './create-certificate.dto'; -import { ApiProperty } from '@nestjs/swagger'; export class UpdateCertificateDto extends PartialType(CreateCertificateDto) {} diff --git a/backend/src/common/dto/paggination.dto.ts b/backend/src/common/dto/paggination.dto.ts deleted file mode 100644 index 23e4eab..0000000 --- a/backend/src/common/dto/paggination.dto.ts +++ /dev/null @@ -1 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; diff --git a/backend/src/courses/courses.controller.ts b/backend/src/courses/courses.controller.ts index 58e8c96..bfde5b8 100644 --- a/backend/src/courses/courses.controller.ts +++ b/backend/src/courses/courses.controller.ts @@ -24,7 +24,6 @@ import { AuthGuard } from '@nestjs/passport'; import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; import { RolesGuard } from '../common/guards/roles.guard'; -import { PaginationDto } from '../common/dto/pagination.dto'; import { CourseFilterDto } from './dto/course-filter.dto'; import { CoursesService } from './courses.service'; import { UserRole } from '../common/enums/user-role.enum'; diff --git a/backend/src/courses/courses.service.spec.ts b/backend/src/courses/courses.service.spec.ts index f2d52a0..1697e70 100644 --- a/backend/src/courses/courses.service.spec.ts +++ b/backend/src/courses/courses.service.spec.ts @@ -5,9 +5,6 @@ import { CoursesService } from './courses.service'; import { Course } from './entities/course.entity'; import { CourseRegistration } from './entities/course-registration.entity'; import { PaginationService } from '../common/services/pagination.service'; -import { Lesson } from '../lessons/entities/lesson.entity'; -import { Progress } from '../progress/entities/progress.entity'; -import { NotificationsService } from '../notifications/notifications.service'; const now = new Date(); diff --git a/backend/src/courses/dto/course-response.dto.ts b/backend/src/courses/dto/course-response.dto.ts index 4a95f9a..4a4e877 100644 --- a/backend/src/courses/dto/course-response.dto.ts +++ b/backend/src/courses/dto/course-response.dto.ts @@ -19,35 +19,46 @@ export class CourseResponseDto { @IsNotEmpty() id: string; - @ApiProperty() + @ApiProperty({ example: 'Intro to Blockchain', description: 'title field' }) @IsString() @IsNotEmpty() title: string; @ApiProperty({ - example: 'A concise description of the resource.', + example: 'A concise description of the course.', description: 'description field', }) @IsString() @IsNotEmpty() description: string; - @ApiProperty() + @ApiProperty({ example: true, required: false }) @IsBoolean() - published: boolean; + @IsOptional() + published?: boolean; - @ApiProperty({ example: 'Beginner', required: false, nullable: true }) + @ApiProperty({ + example: 'Beginner', + description: 'difficulty field', + required: false, + nullable: true, + }) @IsOptional() @IsString() difficulty: string | null; - @ApiProperty({ example: ['bitcoin', 'defi'], type: [String] }) + @ApiProperty({ + example: ['blockchain', 'tech'], + description: 'tags field', + type: [String], + }) @IsArray() @IsString({ each: true }) tags: string[]; @ApiProperty({ example: 'https://example.com/thumb.jpg', + description: 'thumbnailUrl field', required: false, nullable: true, }) @@ -55,7 +66,7 @@ export class CourseResponseDto { @IsUrl() thumbnailUrl: string | null; - @ApiProperty({ example: 42, description: 'Number of enrolled users' }) + @ApiProperty({ example: 120, description: 'enrollmentCount field' }) @IsInt() enrollmentCount: number; @@ -73,7 +84,6 @@ export class CourseResponseDto { @IsDate() updatedAt: Date; - /** Present on GET /courses when the request includes a valid JWT */ @ApiProperty({ example: true, description: 'isEnrolled field', diff --git a/backend/src/courses/dto/create-course.dto.ts b/backend/src/courses/dto/create-course.dto.ts index c75e500..5463289 100644 --- a/backend/src/courses/dto/create-course.dto.ts +++ b/backend/src/courses/dto/create-course.dto.ts @@ -14,10 +14,7 @@ export class CreateCourseDto { @IsNotEmpty() title: string; - @ApiProperty({ - example: 'A concise description of the resource.', - description: 'description field', - }) + @ApiProperty({ example: 'A concise description of the course.' }) @IsString() @IsNotEmpty() description: string; diff --git a/backend/src/courses/dto/update-course.dto.ts b/backend/src/courses/dto/update-course.dto.ts index 09a7a74..26acf7b 100644 --- a/backend/src/courses/dto/update-course.dto.ts +++ b/backend/src/courses/dto/update-course.dto.ts @@ -1,5 +1,4 @@ import { PartialType } from '@nestjs/mapped-types'; import { CreateCourseDto } from './create-course.dto'; -import { ApiProperty } from '@nestjs/swagger'; export class UpdateCourseDto extends PartialType(CreateCourseDto) {} diff --git a/backend/src/currencies/currencies.service.ts b/backend/src/currencies/currencies.service.ts index 81306d2..4ee8da6 100644 --- a/backend/src/currencies/currencies.service.ts +++ b/backend/src/currencies/currencies.service.ts @@ -1,6 +1,6 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository, Like } from 'typeorm'; +import { Repository } from 'typeorm'; import { CurrencyEntry, CurrencyType } from './entities/currency-entry.entity'; import { CreateCurrencyDto, @@ -43,7 +43,7 @@ export class CurrenciesService { const formattedItems = items.map((item) => { // Omit detailed historical data in find all list to reduce payload - const { historicalData, ...rest } = item; + const { historicalData: _historicalData, ...rest } = item; return rest; }); diff --git a/backend/src/lessons/dto/create-lesson.dto.ts b/backend/src/lessons/dto/create-lesson.dto.ts index 814f581..155245d 100644 --- a/backend/src/lessons/dto/create-lesson.dto.ts +++ b/backend/src/lessons/dto/create-lesson.dto.ts @@ -6,6 +6,7 @@ import { IsNumber, Min, IsUrl, + IsBoolean, } from 'class-validator'; import { ApiProperty } from '@nestjs/swagger'; @@ -30,7 +31,16 @@ export class CreateLessonDto { videoUrl?: string; @ApiProperty({ - example: '123e4567-e89b-12d3-a456-426614174000', + example: true, + description: 'published field', + required: false, + }) + @IsBoolean() + @IsOptional() + published?: boolean; + + @ApiProperty({ + example: 0, description: 'videoStartTimestamp field', required: false, }) diff --git a/backend/src/lessons/dto/lesson-response.dto.ts b/backend/src/lessons/dto/lesson-response.dto.ts index 07f422e..2cf81da 100644 --- a/backend/src/lessons/dto/lesson-response.dto.ts +++ b/backend/src/lessons/dto/lesson-response.dto.ts @@ -20,13 +20,15 @@ export class LessonResponseDto { title: string; @ApiProperty({ example: 'example', description: 'content field' }) content: string; + @ApiProperty({ example: true, description: 'published field' }) + published: boolean; @ApiProperty({ - example: '123e4567-e89b-12d3-a456-426614174000', + example: 'https://example.com/video.mp4', description: 'videoUrl field', }) videoUrl: string | null; @ApiProperty({ - example: '123e4567-e89b-12d3-a456-426614174000', + example: 0, description: 'videoStartTimestamp field', }) videoStartTimestamp: number | null; @@ -61,6 +63,7 @@ export class LessonResponseDto { this.id = lesson.id; this.title = lesson.title; this.content = lesson.content; + this.published = lesson.published !== undefined ? lesson.published : true; this.videoUrl = lesson.videoUrl || null; this.videoStartTimestamp = lesson.videoStartTimestamp || null; this.order = lesson.order; diff --git a/backend/src/lessons/dto/update-lesson.dto.ts b/backend/src/lessons/dto/update-lesson.dto.ts index 63a938a..9f63c8a 100644 --- a/backend/src/lessons/dto/update-lesson.dto.ts +++ b/backend/src/lessons/dto/update-lesson.dto.ts @@ -1,5 +1,4 @@ import { PartialType } from '@nestjs/mapped-types'; import { CreateLessonDto } from './create-lesson.dto'; -import { ApiProperty } from '@nestjs/swagger'; export class UpdateLessonDto extends PartialType(CreateLessonDto) {} diff --git a/backend/src/lessons/entities/lesson.entity.ts b/backend/src/lessons/entities/lesson.entity.ts index 286357b..a6d3f5a 100644 --- a/backend/src/lessons/entities/lesson.entity.ts +++ b/backend/src/lessons/entities/lesson.entity.ts @@ -21,6 +21,9 @@ export class Lesson { @Column('text') content: string; + @Column({ default: true }) + published: boolean; + @Column({ type: 'varchar', nullable: true }) videoUrl: string; // External video URL diff --git a/backend/src/lessons/lessons.controller.ts b/backend/src/lessons/lessons.controller.ts index e0451e1..d18df70 100644 --- a/backend/src/lessons/lessons.controller.ts +++ b/backend/src/lessons/lessons.controller.ts @@ -95,6 +95,7 @@ export class LessonsController { courseId, page, limit, + true, ); return { ...result, diff --git a/backend/src/lessons/lessons.service.ts b/backend/src/lessons/lessons.service.ts index 09fa834..2b82a95 100644 --- a/backend/src/lessons/lessons.service.ts +++ b/backend/src/lessons/lessons.service.ts @@ -41,12 +41,19 @@ export class LessonsService { videoStartTimestamp: createLessonDto.videoStartTimestamp, order: createLessonDto.order ?? 0, courseId: createLessonDto.courseId, + published: + createLessonDto.published !== undefined + ? createLessonDto.published + : true, }); return this.lessonRepository.save(lesson); } - async findAllByCourse(courseId: string): Promise { + async findAllByCourse( + courseId: string, + publishedOnly: boolean = false, + ): Promise { // Verify course exists const course = await this.courseRepository.findOne({ where: { id: courseId }, @@ -56,8 +63,13 @@ export class LessonsService { throw new NotFoundException(`Course with ID ${courseId} not found`); } + const whereCondition: any = { courseId }; + if (publishedOnly) { + whereCondition.published = true; + } + return this.lessonRepository.find({ - where: { courseId }, + where: whereCondition, order: { order: 'ASC', createdAt: 'ASC' }, }); } @@ -79,6 +91,7 @@ export class LessonsService { courseId: string, page: number, limit: number, + publishedOnly: boolean = false, ): Promise< PaginatedResult > { @@ -90,11 +103,16 @@ export class LessonsService { throw new NotFoundException(`Course with ID ${courseId} not found`); } + const whereCondition: any = { courseId }; + if (publishedOnly) { + whereCondition.published = true; + } + const result = await this.paginationService.paginate( this.lessonRepository, { page, limit }, { - where: { courseId }, + where: whereCondition, order: { order: 'ASC', createdAt: 'ASC' }, }, ); @@ -176,6 +194,9 @@ export class LessonsService { if (updateLessonDto.order !== undefined) { lesson.order = updateLessonDto.order; } + if (updateLessonDto.published !== undefined) { + lesson.published = updateLessonDto.published; + } return this.lessonRepository.save(lesson); } @@ -207,4 +228,17 @@ export class LessonsService { ), ); } + + async setPublished(id: string, published: boolean): Promise { + const lesson = await this.lessonRepository.findOne({ + where: { id }, + }); + + if (!lesson) { + throw new NotFoundException(`Lesson with ID ${id} not found`); + } + + lesson.published = published; + return this.lessonRepository.save(lesson); + } } diff --git a/backend/src/progress/dto/create-progress.dto.ts b/backend/src/progress/dto/create-progress.dto.ts index 57c9fca..53914d4 100644 --- a/backend/src/progress/dto/create-progress.dto.ts +++ b/backend/src/progress/dto/create-progress.dto.ts @@ -1,2 +1 @@ -import { ApiProperty } from '@nestjs/swagger'; export class CreateProgressDto {} diff --git a/backend/src/progress/dto/update-progress.dto.ts b/backend/src/progress/dto/update-progress.dto.ts index d5587cf..88ee423 100644 --- a/backend/src/progress/dto/update-progress.dto.ts +++ b/backend/src/progress/dto/update-progress.dto.ts @@ -1,5 +1,4 @@ import { PartialType } from '@nestjs/mapped-types'; import { CreateProgressDto } from './create-progress.dto'; -import { ApiProperty } from '@nestjs/swagger'; export class UpdateProgressDto extends PartialType(CreateProgressDto) {} diff --git a/backend/src/quizzes/dto/update-quiz.dto.ts b/backend/src/quizzes/dto/update-quiz.dto.ts index 3dc47ae..494f3af 100644 --- a/backend/src/quizzes/dto/update-quiz.dto.ts +++ b/backend/src/quizzes/dto/update-quiz.dto.ts @@ -1,5 +1,4 @@ import { PartialType } from '@nestjs/mapped-types'; import { CreateQuizDto } from './create-quiz.dto'; -import { ApiProperty } from '@nestjs/swagger'; export class UpdateQuizDto extends PartialType(CreateQuizDto) {} diff --git a/backend/src/rewards/dto/create-reward.dto.ts b/backend/src/rewards/dto/create-reward.dto.ts index 6b82f20..69a443b 100644 --- a/backend/src/rewards/dto/create-reward.dto.ts +++ b/backend/src/rewards/dto/create-reward.dto.ts @@ -1,2 +1 @@ -import { ApiProperty } from '@nestjs/swagger'; export class CreateRewardDto {} diff --git a/backend/src/rewards/dto/update-reward.dto.ts b/backend/src/rewards/dto/update-reward.dto.ts index 238590c..263419b 100644 --- a/backend/src/rewards/dto/update-reward.dto.ts +++ b/backend/src/rewards/dto/update-reward.dto.ts @@ -1,5 +1,4 @@ import { PartialType } from '@nestjs/mapped-types'; import { CreateRewardDto } from './create-reward.dto'; -import { ApiProperty } from '@nestjs/swagger'; export class UpdateRewardDto extends PartialType(CreateRewardDto) {} diff --git a/backend/src/rewards/rewards.controller.ts b/backend/src/rewards/rewards.controller.ts index 7d33205..9cda998 100644 --- a/backend/src/rewards/rewards.controller.ts +++ b/backend/src/rewards/rewards.controller.ts @@ -26,9 +26,6 @@ export class RewardsController { @Get('my') @UseGuards(JwtAuthGuard) @ApiBearerAuth('access-token') - @ApiOperation({ summary: 'Get my rewards' }) - @ApiResponse({ status: 200, description: 'Rewards retrieved successfully' }) - @ApiResponse({ status: 401, description: 'Unauthorized' }) @ApiOperation({ summary: 'Get my rewards', description: diff --git a/backend/src/rewards/rewards.service.ts b/backend/src/rewards/rewards.service.ts index 1c04832..e5af966 100644 --- a/backend/src/rewards/rewards.service.ts +++ b/backend/src/rewards/rewards.service.ts @@ -10,6 +10,11 @@ import { } from './entities/reward-history.entity'; import { UserBadge } from './entities/user-badge.entity'; +import { NotificationsService } from '../notifications/notifications.service'; +import { WebhooksService } from '../webhooks/webhooks.service'; +import { WebhookEvent } from '../webhooks/dto/create-webhook.dto'; +import { NotificationType } from '../notifications/entities/notification.entity'; + export const XP_LESSON_COMPLETE = 10; export const XP_QUIZ_PASS = 25; export const XP_COURSE_COMPLETE = 100; @@ -34,6 +39,8 @@ export class RewardsService { private readonly userRepository: Repository, @InjectRepository(RewardHistory) private readonly rewardHistoryRepository: Repository, + private readonly notificationsService: NotificationsService, + private readonly webhooksService: WebhooksService, ) {} async awardXP( @@ -103,10 +110,29 @@ export class RewardsService { continue; } - await this.userBadgeRepository.save( - this.userBadgeRepository.create({ userId, badgeId: badge.id }), - ); - newlyEarned.push(badge); + try { + await this.userBadgeRepository.save( + this.userBadgeRepository.create({ userId, badgeId: badge.id }), + ); + newlyEarned.push(badge); + + await this.notificationsService.createNotification( + userId, + NotificationType.BADGE_EARNED, + `You earned a new badge: ${badge.name}.`, + '/rewards', + ); + + // Dispatch webhook event + await this.webhooksService.dispatchEvent(WebhookEvent.BADGE_EARNED, { + userId, + badgeId: badge.id, + badgeName: badge.name, + awardedAt: new Date(), + }); + } catch { + // Unique constraint race: ignore + } } return newlyEarned; diff --git a/backend/src/users/dto/create-user.dto.ts b/backend/src/users/dto/create-user.dto.ts index 0875b14..0311be1 100644 --- a/backend/src/users/dto/create-user.dto.ts +++ b/backend/src/users/dto/create-user.dto.ts @@ -1,2 +1 @@ -import { ApiProperty } from '@nestjs/swagger'; export class CreateUserDto {} diff --git a/backend/src/users/dto/update-user.dto.ts b/backend/src/users/dto/update-user.dto.ts index 270a0e4..dfd37fb 100644 --- a/backend/src/users/dto/update-user.dto.ts +++ b/backend/src/users/dto/update-user.dto.ts @@ -1,5 +1,4 @@ import { PartialType } from '@nestjs/mapped-types'; import { CreateUserDto } from './create-user.dto'; -import { ApiProperty } from '@nestjs/swagger'; export class UpdateUserDto extends PartialType(CreateUserDto) {} diff --git a/backend/src/users/streak.service.ts b/backend/src/users/streak.service.ts index 8b2223f..df06c74 100644 --- a/backend/src/users/streak.service.ts +++ b/backend/src/users/streak.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository, LessThan } from 'typeorm'; +import { Repository } from 'typeorm'; import { Cron, CronExpression } from '@nestjs/schedule'; import { User } from './entities/user.entity'; import { RewardsService } from '../rewards/rewards.service'; diff --git a/backend/src/users/users.controller.spec.ts b/backend/src/users/users.controller.spec.ts index 9533179..4160787 100644 --- a/backend/src/users/users.controller.spec.ts +++ b/backend/src/users/users.controller.spec.ts @@ -116,4 +116,49 @@ describe('UsersController', () => { expect(result).toBeUndefined(); }); }); + + describe('POST /me/avatar', () => { + const validFile = { + size: 1024, + mimetype: 'image/png', + originalname: 'avatar.png', + buffer: Buffer.from('fake-image'), + }; + + it('returns avatarUrl on successful upload', async () => { + userService.uploadAvatar = jest.fn().mockResolvedValue({ + avatarUrl: '/uploads/avatars/uuid.png', + }); + + const result = await controller.uploadMyAvatar(mockRequest, validFile as any); + + expect(userService.uploadAvatar).toHaveBeenCalledWith( + 'user-1', + validFile, + ); + expect(result).toEqual({ avatarUrl: '/uploads/avatars/uuid.png' }); + }); + + it('propagates BadRequestException from service for non-image files', async () => { + userService.uploadAvatar = jest.fn().mockRejectedValue( + new BadRequestException('Only image files are allowed'), + ); + const nonImageFile = { ...validFile, mimetype: 'application/pdf' }; + + await expect( + controller.uploadMyAvatar(mockRequest, nonImageFile as any), + ).rejects.toThrow(BadRequestException); + }); + + it('propagates BadRequestException from service for oversized files', async () => { + userService.uploadAvatar = jest.fn().mockRejectedValue( + new BadRequestException('Avatar file size must be <= 2MB'), + ); + const largeFile = { ...validFile, size: 999 * 1024 * 1024 }; + + await expect( + controller.uploadMyAvatar(mockRequest, largeFile as any), + ).rejects.toThrow(BadRequestException); + }); + }); }); diff --git a/backend/src/users/users.controller.ts b/backend/src/users/users.controller.ts index d86362b..3b04e45 100644 --- a/backend/src/users/users.controller.ts +++ b/backend/src/users/users.controller.ts @@ -85,7 +85,6 @@ export class UsersController { ) { return this.userService.uploadAvatar(req.user.id as string, file); } - @Post('me/wallet/challenge') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'Generate wallet verification challenge' }) diff --git a/backend/src/webhooks/dto/create-webhook.dto.ts b/backend/src/webhooks/dto/create-webhook.dto.ts index d1f8515..1410ecb 100644 --- a/backend/src/webhooks/dto/create-webhook.dto.ts +++ b/backend/src/webhooks/dto/create-webhook.dto.ts @@ -1,4 +1,4 @@ -import { IsUrl, IsString, IsArray, IsEnum, IsNotEmpty } from 'class-validator'; +import { IsUrl, IsArray, IsEnum, IsNotEmpty } from 'class-validator'; import { ApiProperty } from '@nestjs/swagger'; export enum WebhookEvent { diff --git a/backend/tsconfig.json b/backend/tsconfig.json index 118f151..73d5fa0 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -19,5 +19,5 @@ "strictBindCallApply": false, "noFallthroughCasesInSwitch": false }, - "exclude": ["node_modules", "scripts"] + "exclude": ["node_modules", "scripts", "dist"] } diff --git a/frontend/app/admin/page.tsx b/frontend/app/admin/page.tsx index e2decaa..bd7471c 100644 --- a/frontend/app/admin/page.tsx +++ b/frontend/app/admin/page.tsx @@ -15,7 +15,7 @@ interface Course { } export default function AdminPage() { - const { data, isLoading, isError, refetch } = useQuery({ + const { data, isLoading, isError, refetch } = useQuery({ queryKey: ["admin-courses-list"], queryFn: async () => { const res = await api.get<{ data?: Course[] } | Course[]>("/courses?limit=100") diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index bb29da9..a9782e4 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -4,7 +4,6 @@ import { Toaster } from "sonner"; import "./globals.css"; import { AuthProvider } from "@/contexts/auth-context"; import { LearningProvider } from "@/contexts/learning-context"; -import { Providers } from "@/components/providers"; import { UserProvider } from "@/contexts/user-context"; import { ReactQueryProvider } from "@/lib/query-client"; diff --git a/frontend/app/profile/page.tsx b/frontend/app/profile/page.tsx index 2660f6e..e8058e5 100644 --- a/frontend/app/profile/page.tsx +++ b/frontend/app/profile/page.tsx @@ -1,6 +1,6 @@ "use client"; -import Image from "next/image"; + import { Header } from "@/components/header"; import { Card, CardContent } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; @@ -11,16 +11,12 @@ import { useEffect } from "react"; import { AvatarUpload } from "@/components/profile/avatar-upload"; import { StatsSummary } from "@/components/profile/stats-summary"; import { ProfileTabs } from "@/components/profile/profile-tabs"; -import { ArrowLeft, Mail, Shield, Settings, - User as UserIcon, Loader2, - ExternalLink, Calendar, - MapPin, Sparkles } from "lucide-react"; import Link from "next/link"; @@ -142,7 +138,6 @@ export default function ProfilePage() { streak={stats.streakDays} badgesCount={stats.badgesCount} certificatesCount={stats.certificatesCount} - completedCourses={userStats?.completedCourseCount ?? 0} />
diff --git a/frontend/app/settings/page.tsx b/frontend/app/settings/page.tsx index fbc27d1..fe47801 100644 --- a/frontend/app/settings/page.tsx +++ b/frontend/app/settings/page.tsx @@ -3,7 +3,7 @@ import { useEffect, useMemo, useState, useCallback } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; -import { ArrowLeft, Bell, LogOut, Save, Settings, User, CheckCircle, Sparkles, Shield, Mail } from "lucide-react"; +import { ArrowLeft, Bell, LogOut, Save, Settings, User, CheckCircle, Sparkles } from "lucide-react"; import { toast } from "sonner"; import { Header } from "@/components/header"; import { Button } from "@/components/ui/button"; diff --git a/frontend/app/verify-certificate/page.tsx b/frontend/app/verify-certificate/page.tsx index 36d083e..8e45452 100644 --- a/frontend/app/verify-certificate/page.tsx +++ b/frontend/app/verify-certificate/page.tsx @@ -23,7 +23,7 @@ function VerifyCertificateContent() { const [inputValue, setInputValue] = useState(initialHash); const [activeHash, setActiveHash] = useState(initialHash); - const { data: result, isLoading: loading, isError, refetch } = useCertificateVerification(activeHash); + const { data: result, isLoading: loading } = useCertificateVerification(activeHash); const handleVerify = (e?: React.FormEvent) => { if (e) e.preventDefault(); diff --git a/frontend/components/certificates/certificate-card.tsx b/frontend/components/certificates/certificate-card.tsx index 0b1d437..1c78c6f 100644 --- a/frontend/components/certificates/certificate-card.tsx +++ b/frontend/components/certificates/certificate-card.tsx @@ -6,7 +6,6 @@ import { Download, Share2, Check, - ExternalLink, Loader2, Calendar, Award @@ -15,7 +14,7 @@ import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { toast } from "sonner"; import { Certificate } from "@/hooks/use-certificates"; -import { api } from "@/lib/api"; + interface CertificateCardProps { certificate: Certificate; diff --git a/frontend/components/profile/my-certificates-content.tsx b/frontend/components/profile/my-certificates-content.tsx index 325dea6..e7ad6a1 100644 --- a/frontend/components/profile/my-certificates-content.tsx +++ b/frontend/components/profile/my-certificates-content.tsx @@ -1,8 +1,8 @@ "use client"; -import { AlertTriangle, Loader2, Award } from "lucide-react"; +import { AlertTriangle, Award } from "lucide-react"; import { CertificateCard } from "@/components/certificates/certificate-card"; -import { useCertificates } from "@/hooks/use-certificates"; +import { useCertificates, Certificate } from "@/hooks/use-certificates"; import { Button } from "@/components/ui/button"; import Link from "next/link"; @@ -57,7 +57,7 @@ export function MyCertificatesContent() { return (
- {certificates.map((cert) => ( + {certificates.map((cert: Certificate) => ( ))}
diff --git a/frontend/components/profile/stats-summary.tsx b/frontend/components/profile/stats-summary.tsx index 2b111be..2508858 100644 --- a/frontend/components/profile/stats-summary.tsx +++ b/frontend/components/profile/stats-summary.tsx @@ -1,14 +1,13 @@ "use client"; import { Card, CardContent } from "@/components/ui/card"; -import { Award, Flame, GraduationCap, Trophy, Zap, Sparkles } from "lucide-react"; +import { Award, Flame, GraduationCap, Zap } from "lucide-react"; interface StatsSummaryProps { xp: number; streak: number; badgesCount: number; certificatesCount: number; - completedCourses: number; } export function StatsSummary({ @@ -16,7 +15,6 @@ export function StatsSummary({ streak, badgesCount, certificatesCount, - completedCourses, }: StatsSummaryProps) { const cards = [ { diff --git a/frontend/contexts/learning-context.tsx b/frontend/contexts/learning-context.tsx index 98895dd..76885b2 100644 --- a/frontend/contexts/learning-context.tsx +++ b/frontend/contexts/learning-context.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { createContext, useContext, useState, useEffect } from "react"; +import React, { createContext, useContext, useState, useEffect, useCallback } from "react"; import { toast } from "sonner"; import { api } from "@/lib/api"; import { PLACEHOLDER_VIDEO_URL } from "@/lib/constants"; @@ -61,6 +61,7 @@ interface LearningContextType { quizResults: Record; isSubmittingQuiz: boolean; isCompletingLesson: boolean; + enrollInCourse: (courseId: string) => Promise; markLessonComplete: (courseId: string, lessonId: string) => Promise; submitQuiz: ( quizId: string, @@ -358,6 +359,41 @@ export function LearningProvider({ children }: { children: React.ReactNode }) { const [isSubmittingQuiz, setIsSubmittingQuiz] = useState(false); const [isCompletingLesson, setIsCompletingLesson] = useState(false); + const fetchCourses = useCallback(async () => { + const token = typeof window !== "undefined" ? localStorage.getItem("auth_token") : null; + if (!token) return; + try { + interface RawCourseResponse { + id: string; + title: string; + description: string; + progressPercent?: number; + isEnrolled?: boolean; + } + const data = await api.get("/courses"); + const list = Array.isArray(data) ? data : (data as { data: RawCourseResponse[] }).data ?? []; + setCourses( + list.map((c) => ({ + id: c.id, + title: c.title, + description: c.description, + difficulty: "Beginner" as const, + rating: 0, + duration: 0, + lessons: [], + progress: c.progressPercent ?? 0, + enrolled: c.isEnrolled ?? false, + })) + ); + } catch { + // silently fail — courses stay as is (mock or cached) + } + }, []); + + useEffect(() => { + void fetchCourses(); + }, [fetchCourses]); + // Save to localStorage whenever courses or quizResults change useEffect(() => { if (typeof window !== "undefined") { @@ -374,6 +410,16 @@ export function LearningProvider({ children }: { children: React.ReactNode }) { } }, [quizResults]); + const enrollInCourse = async (courseId: string) => { + try { + await api.post(`/courses/${courseId}/enroll`, {}); + await fetchCourses(); + toast.success("Enrolled successfully!"); + } catch { + toast.error("Failed to enroll in course."); + } + }; + const markLessonComplete = async ( courseId: string, lessonId: string, @@ -398,17 +444,17 @@ export function LearningProvider({ children }: { children: React.ReactNode }) { progressRows.map((row) => [row.lessonId, row.completed]), ); - setCourses((prev) => - prev.map((course) => { + setCourses((prev: Course[]) => + prev.map((course: Course) => { if (course.id !== courseId) { return course; } - const updatedLessons = course.lessons.map((lesson) => ({ + const updatedLessons = course.lessons.map((lesson: Lesson) => ({ ...lesson, completed: completedByLessonId.get(lesson.id) ?? lesson.completed, })); const completedCount = updatedLessons.filter( - (l) => l.completed, + (l: Lesson) => l.completed, ).length; const progress = updatedLessons.length ? (completedCount / updatedLessons.length) * 100 @@ -469,7 +515,7 @@ export function LearningProvider({ children }: { children: React.ReactNode }) { submittedAt: submission.submittedAt, }; - setQuizResults((prev) => ({ ...prev, [quizId]: result })); + setQuizResults((prev: Record) => ({ ...prev, [quizId]: result })); return result; } catch (error) { const status = (error as { status?: number }).status; @@ -487,7 +533,7 @@ export function LearningProvider({ children }: { children: React.ReactNode }) { }; const getCourseProgress = (courseId: string): number => { - const course = courses.find((c) => c.id === courseId); + const course = courses.find((c: Course) => c.id === courseId); return course?.progress || 0; }; diff --git a/frontend/contexts/user-context.tsx b/frontend/contexts/user-context.tsx index d495235..38b244f 100644 --- a/frontend/contexts/user-context.tsx +++ b/frontend/contexts/user-context.tsx @@ -178,7 +178,7 @@ export function UserProvider({ children }: { children: React.ReactNode }) { } catch (err) { console.error("Failed to load user data:", err); // Ensure we have at least a fallback user if token exists - setUser((prev) => prev ?? getLocalFallbackUser()); + setUser((prev: UserProfile | null) => prev ?? getLocalFallbackUser()); } }, [getLocalFallbackUser, mapUser]); @@ -205,7 +205,7 @@ export function UserProvider({ children }: { children: React.ReactNode }) { updates.email === undefined && updates.bio === undefined ) { - setUser((prev) => (prev ? { ...prev, avatar: updates.avatar } : prev)); + setUser((prev: UserProfile | null) => (prev ? { ...prev, avatar: updates.avatar } : prev)); return; } @@ -253,7 +253,7 @@ export function UserProvider({ children }: { children: React.ReactNode }) { ...defaultNotificationPreferences, ...(updated.notificationPreferences ?? updatedPrefs), }); - setUser((prev) => { + setUser((prev: UserProfile | null) => { if (!prev) return prev; return mapUser({ ...updated, diff --git a/frontend/e2e/learning.spec.ts b/frontend/e2e/learning.spec.ts index 5a4e1cb..9ba4e5b 100644 --- a/frontend/e2e/learning.spec.ts +++ b/frontend/e2e/learning.spec.ts @@ -4,7 +4,7 @@ test.describe.serial('Learning Flow', () => { const testEmail = `learner_${Date.now()}@example.com`; const testPassword = 'Password123!'; - test.beforeAll(async ({ request }) => { + test.beforeAll(async () => { // Optionally create a user here via API if needed, // but the test can also just register via UI });