Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions backend/src/auth/auth.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() } },
Expand Down
2 changes: 2 additions & 0 deletions backend/src/certificates/certificates.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -41,6 +42,7 @@ const makeCertRepo = () => ({
find: jest.fn(),
create: jest.fn(),
save: jest.fn(),
});
createQueryBuilder: jest.fn(),
});

Expand Down
19 changes: 19 additions & 0 deletions backend/src/courses/courses.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
1 change: 1 addition & 0 deletions backend/src/notifications/entities/notification.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
3 changes: 3 additions & 0 deletions backend/src/progress/progress.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -23,6 +24,7 @@ export class ProgressService {
private readonly certificateService: CertificateService,
private readonly notificationsService: NotificationsService,
private readonly rewardsService: RewardsService,
private readonly streakService: StreakService,
) {}

/**
Expand Down Expand Up @@ -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(
Expand Down
3 changes: 3 additions & 0 deletions backend/src/quizzes/quizzes.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -32,6 +33,7 @@ export class QuizzesService {
private quizSubmissionRepository: Repository<QuizSubmission>,
private readonly notificationsService: NotificationsService,
private readonly rewardsService: RewardsService,
private readonly streakService: StreakService,
) {}

async create(createQuizDto: CreateQuizDto): Promise<Quiz> {
Expand Down Expand Up @@ -234,6 +236,7 @@ export class QuizzesService {
`You passed the quiz "${quiz.title}".`,
`/courses/lessons/${quiz.lessonId}`,
);
await this.streakService.updateStreak(userId);
}

return savedSubmission;
Expand Down
1 change: 1 addition & 0 deletions backend/src/rewards/entities/reward-history.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
6 changes: 6 additions & 0 deletions backend/src/users/entities/user.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
135 changes: 135 additions & 0 deletions backend/src/users/streak.service.ts
Original file line number Diff line number Diff line change
@@ -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<User>,
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<void> {
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<void> {
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<void> {
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);
}
}
}
2 changes: 2 additions & 0 deletions backend/src/users/users.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,8 @@ export class UsersController {
certificateCount: number;
xp: number;
streak: number;
longestStreak: number;
lastActiveAt: Date | null;
badgesCount: number;
rank: number;
}> {
Expand Down
5 changes: 3 additions & 2 deletions backend/src/users/users.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand All @@ -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 {}
4 changes: 4 additions & 0 deletions backend/src/users/users.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,8 @@ export class UserService {
certificateCount: number;
xp: number;
streak: number;
longestStreak: number;
lastActiveAt: Date | null;
badgesCount: number;
rank: number;
}> {
Expand All @@ -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,
};
Expand Down
6 changes: 6 additions & 0 deletions frontend/contexts/user-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down