diff --git a/backend/pnpm-lock.yaml b/backend/pnpm-lock.yaml index 490946b..2d5c4ac 100644 --- a/backend/pnpm-lock.yaml +++ b/backend/pnpm-lock.yaml @@ -4967,6 +4967,7 @@ snapshots: keyv: 5.6.0 rxjs: 7.8.2 + '@nestjs/cli@11.0.16(@swc/cli@0.6.0(@swc/core@1.15.11)(chokidar@4.0.3))(@swc/core@1.15.11)(@types/node@22.19.11)': '@nestjs/cli@11.0.16(@swc/cli@0.6.0(@swc/core@1.15.11(@swc/helpers@0.5.20))(chokidar@4.0.3))(@swc/core@1.15.11(@swc/helpers@0.5.20))(@types/node@22.19.11)': dependencies: '@angular-devkit/core': 19.2.19(chokidar@4.0.3) diff --git a/backend/src/auth/auth.service.spec.ts b/backend/src/auth/auth.service.spec.ts index 1414074..b3f32fc 100644 --- a/backend/src/auth/auth.service.spec.ts +++ b/backend/src/auth/auth.service.spec.ts @@ -39,6 +39,13 @@ describe('AuthService', () => { const module: TestingModule = await Test.createTestingModule({ providers: [ AuthService, + { + provide: UserService, + useValue: { create: jest.fn(), findByEmail: jest.fn() }, + }, + { + provide: JwtService, + useValue: { sign: jest.fn(), verify: jest.fn() }, { provide: UserService, useValue: userService }, { provide: JwtService, useValue: jwtService }, { provide: ConfigService, useValue: { get: jest.fn() } }, diff --git a/backend/src/certificates/certificates.service.spec.ts b/backend/src/certificates/certificates.service.spec.ts index b410a13..6e33eca 100644 --- a/backend/src/certificates/certificates.service.spec.ts +++ b/backend/src/certificates/certificates.service.spec.ts @@ -9,6 +9,7 @@ import { NotificationsService } from 'src/notifications/notifications.service'; import { ConfigService } from '@nestjs/config'; import { EmailService } from 'src/email/email.service'; +const mockRepo = () => ({ const now = new Date(); const mockUser = { @@ -41,6 +42,7 @@ const makeCertRepo = () => ({ find: jest.fn(), create: jest.fn(), save: jest.fn(), +}); createQueryBuilder: jest.fn(), }); diff --git a/backend/src/courses/courses.service.spec.ts b/backend/src/courses/courses.service.spec.ts index cc5e234..55a3e81 100644 --- a/backend/src/courses/courses.service.spec.ts +++ b/backend/src/courses/courses.service.spec.ts @@ -59,6 +59,25 @@ describe('CoursesService', () => { const module: TestingModule = await Test.createTestingModule({ providers: [ CoursesService, + { provide: getRepositoryToken(Course), useValue: mockRepo() }, + { + provide: getRepositoryToken(CourseRegistration), + useValue: mockRepo(), + }, + { provide: getRepositoryToken(Lesson), useValue: mockRepo() }, + { provide: getRepositoryToken(Progress), useValue: mockRepo() }, + { + provide: PaginationService, + useValue: { + paginate: jest.fn().mockResolvedValue({ + data: [], + total: 0, + page: 1, + limit: 10, + totalPages: 0, + }), + }, + }, { provide: getRepositoryToken(Course), useValue: courseRepo }, { provide: getRepositoryToken(CourseRegistration), useValue: regRepo }, { provide: getRepositoryToken(Lesson), useValue: lessonRepo }, diff --git a/backend/src/notifications/entities/notification.entity.ts b/backend/src/notifications/entities/notification.entity.ts index ba59a65..90ca809 100644 --- a/backend/src/notifications/entities/notification.entity.ts +++ b/backend/src/notifications/entities/notification.entity.ts @@ -15,6 +15,7 @@ export enum NotificationType { QUIZ_PASSED = 'QUIZ_PASSED', COURSE_COMPLETE = 'COURSE_COMPLETE', NEW_CONTENT = 'NEW_CONTENT', + STREAK_MILESTONE = 'STREAK_MILESTONE', } @Entity('notifications') diff --git a/backend/src/progress/progress.service.ts b/backend/src/progress/progress.service.ts index 9eb4e45..31dd7f6 100644 --- a/backend/src/progress/progress.service.ts +++ b/backend/src/progress/progress.service.ts @@ -12,6 +12,7 @@ import { XP_LESSON_COMPLETE, } from 'src/rewards/rewards.service'; import { XpRewardReason } from 'src/rewards/entities/reward-history.entity'; +import { StreakService } from 'src/users/streak.service'; @Injectable() export class ProgressService { @@ -23,6 +24,7 @@ export class ProgressService { private readonly certificateService: CertificateService, private readonly notificationsService: NotificationsService, private readonly rewardsService: RewardsService, + private readonly streakService: StreakService, ) {} /** @@ -70,6 +72,7 @@ export class ProgressService { 'You completed a lesson.', `/courses/${courseId}/lessons/${lessonId}`, ); + await this.streakService.updateStreak(userId); } const allLessonsCompleted = await this.checkAllLessonsCompleted( diff --git a/backend/src/quizzes/quizzes.service.ts b/backend/src/quizzes/quizzes.service.ts index ba201d4..19f1c50 100644 --- a/backend/src/quizzes/quizzes.service.ts +++ b/backend/src/quizzes/quizzes.service.ts @@ -18,6 +18,7 @@ import { NotificationType } from 'src/notifications/entities/notification.entity import { RewardsService } from 'src/rewards/rewards.service'; import { XP_QUIZ_PASS } from 'src/rewards/rewards.service'; import { XpRewardReason } from 'src/rewards/entities/reward-history.entity'; +import { StreakService } from 'src/users/streak.service'; @Injectable() export class QuizzesService { @@ -32,6 +33,7 @@ export class QuizzesService { private quizSubmissionRepository: Repository, private readonly notificationsService: NotificationsService, private readonly rewardsService: RewardsService, + private readonly streakService: StreakService, ) {} async create(createQuizDto: CreateQuizDto): Promise { @@ -234,6 +236,7 @@ export class QuizzesService { `You passed the quiz "${quiz.title}".`, `/courses/lessons/${quiz.lessonId}`, ); + await this.streakService.updateStreak(userId); } return savedSubmission; diff --git a/backend/src/rewards/entities/reward-history.entity.ts b/backend/src/rewards/entities/reward-history.entity.ts index fd19add..4acf519 100644 --- a/backend/src/rewards/entities/reward-history.entity.ts +++ b/backend/src/rewards/entities/reward-history.entity.ts @@ -12,6 +12,7 @@ export enum XpRewardReason { LESSON_COMPLETE = 'LESSON_COMPLETE', QUIZ_PASS = 'QUIZ_PASS', COURSE_COMPLETE = 'COURSE_COMPLETE', + STREAK_MILESTONE = 'STREAK_MILESTONE', } @Entity('reward_history') diff --git a/backend/src/users/entities/user.entity.ts b/backend/src/users/entities/user.entity.ts index 661ea3d..c66c4db 100644 --- a/backend/src/users/entities/user.entity.ts +++ b/backend/src/users/entities/user.entity.ts @@ -60,6 +60,12 @@ export class User { @Column({ type: 'int', default: 0 }) streak: number; + @Column({ type: 'int', default: 0 }) + longestStreak: number; + + @Column({ type: 'datetime', nullable: true }) + lastActiveAt: Date; + @Column({ nullable: true }) @Exclude() resetToken: string; diff --git a/backend/src/users/streak.service.ts b/backend/src/users/streak.service.ts new file mode 100644 index 0000000..8b2223f --- /dev/null +++ b/backend/src/users/streak.service.ts @@ -0,0 +1,135 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, LessThan } from 'typeorm'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { User } from './entities/user.entity'; +import { RewardsService } from '../rewards/rewards.service'; +import { NotificationsService } from '../notifications/notifications.service'; +import { NotificationType } from '../notifications/entities/notification.entity'; +import { XpRewardReason } from '../rewards/entities/reward-history.entity'; + +@Injectable() +export class StreakService { + constructor( + @InjectRepository(User) + private readonly userRepository: Repository, + private readonly rewardsService: RewardsService, + private readonly notificationsService: NotificationsService, + ) {} + + /** + * Updates the user's streak based on activity. + * - If lastActiveAt was yesterday, increment streak. + * - If lastActiveAt was more than 1 day ago, reset streak to 1. + * - If lastActiveAt is today, do nothing. + * - Update longestStreak if current streak exceeds it. + * - Check for milestones and award bonuses. + */ + async updateStreak(userId: string): Promise { + const user = await this.userRepository.findOne({ + where: { id: userId }, + }); + + if (!user) { + return; + } + + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const yesterday = new Date(today); + yesterday.setDate(yesterday.getDate() - 1); + + let newStreak = user.streak || 0; + let shouldUpdateLastActiveAt = true; + + if (user.lastActiveAt) { + const lastActiveDate = new Date(user.lastActiveAt); + const lastActiveDay = new Date( + lastActiveDate.getFullYear(), + lastActiveDate.getMonth(), + lastActiveDate.getDate(), + ); + + if (lastActiveDay.getTime() === today.getTime()) { + // Already active today, no change + shouldUpdateLastActiveAt = false; + } else if (lastActiveDay.getTime() === yesterday.getTime()) { + // Active yesterday, increment streak + newStreak += 1; + } else { + // Gap in activity, reset to 1 + newStreak = 1; + } + } else { + // First activity, start streak at 1 + newStreak = 1; + } + + // Update longestStreak if needed + if (newStreak > (user.longestStreak || 0)) { + user.longestStreak = newStreak; + } + + user.streak = newStreak; + + if (shouldUpdateLastActiveAt) { + user.lastActiveAt = now; + } + + await this.userRepository.save(user); + + // Check for streak milestones + await this.checkStreakMilestones(userId, newStreak); + } + + /** + * Checks if the current streak hits a milestone and awards bonus XP and notification. + */ + private async checkStreakMilestones( + userId: string, + streak: number, + ): Promise { + const milestones = [ + { days: 3, xp: 15 }, + { days: 7, xp: 50 }, + { days: 14, xp: 100 }, + { days: 30, xp: 200 }, + ]; + + const milestone = milestones.find((m) => m.days === streak); + if (milestone) { + await this.rewardsService.awardXP( + userId, + milestone.xp, + XpRewardReason.STREAK_MILESTONE, + ); + await this.notificationsService.createNotification( + userId, + NotificationType.STREAK_MILESTONE, + `Congratulations! You've reached a ${streak}-day learning streak and earned ${milestone.xp} bonus XP!`, + ); + } + } + + /** + * Daily cron job to reset streaks for users inactive for more than 48 hours. + */ + @Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT) + async resetInactiveStreaks(): Promise { + const cutoff = new Date(); + cutoff.setHours(cutoff.getHours() - 48); + + const usersToReset = await this.userRepository + .createQueryBuilder('user') + .where('user.streak > 0') + .andWhere('user.lastActiveAt < :cutoff OR user.lastActiveAt IS NULL', { + cutoff, + }) + .getMany(); + + for (const user of usersToReset) { + user.streak = 0; + await this.userRepository.save(user); + } + } +} diff --git a/backend/src/users/users.controller.ts b/backend/src/users/users.controller.ts index 42b81b7..d903dd1 100644 --- a/backend/src/users/users.controller.ts +++ b/backend/src/users/users.controller.ts @@ -113,6 +113,8 @@ export class UsersController { certificateCount: number; xp: number; streak: number; + longestStreak: number; + lastActiveAt: Date | null; badgesCount: number; rank: number; }> { diff --git a/backend/src/users/users.module.ts b/backend/src/users/users.module.ts index b87955a..92a4b00 100644 --- a/backend/src/users/users.module.ts +++ b/backend/src/users/users.module.ts @@ -9,6 +9,7 @@ import { CoursesModule } from '../courses/courses.module'; import { Certificate } from '../certificates/entities/certificate.entity'; import { UserBadge } from '../rewards/entities/user-badge.entity'; import { CourseRegistration } from '../courses/entities/course-registration.entity'; +import { StreakService } from './streak.service'; @Module({ imports: [ @@ -22,7 +23,7 @@ import { CourseRegistration } from '../courses/entities/course-registration.enti forwardRef(() => CoursesModule), ], controllers: [UsersController], - providers: [UserService], - exports: [UserService], + providers: [UserService, StreakService], + exports: [UserService, StreakService], }) export class UsersModule {} diff --git a/backend/src/users/users.service.ts b/backend/src/users/users.service.ts index 523f2d0..18d94eb 100644 --- a/backend/src/users/users.service.ts +++ b/backend/src/users/users.service.ts @@ -247,6 +247,8 @@ export class UserService { certificateCount: number; xp: number; streak: number; + longestStreak: number; + lastActiveAt: Date | null; badgesCount: number; rank: number; }> { @@ -269,6 +271,8 @@ export class UserService { certificateCount, xp: resolvedXp, streak: user.streak ?? 0, + longestStreak: user.longestStreak ?? 0, + lastActiveAt: user.lastActiveAt ?? null, badgesCount, rank: usersAhead + 1, }; diff --git a/frontend/contexts/user-context.tsx b/frontend/contexts/user-context.tsx index 298e35c..8cd29a6 100644 --- a/frontend/contexts/user-context.tsx +++ b/frontend/contexts/user-context.tsx @@ -27,8 +27,14 @@ export interface LearningStats { export interface UserStats { courseCount: number; + completedCourseCount: number; certificateCount: number; xp: number; + streak: number; + longestStreak: number; + lastActiveAt: string | null; + badgesCount: number; + rank: number; } export interface NotificationPreferences {