Skip to content

Commit

Permalink
feat: Backend logic to track statistics
Browse files Browse the repository at this point in the history
  • Loading branch information
typeWolffo committed Nov 19, 2024
1 parent df3c6ea commit 34d6b10
Show file tree
Hide file tree
Showing 26 changed files with 2,923 additions and 79 deletions.
1 change: 1 addition & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
"bcrypt": "^5.1.1",
"cookie": "^0.6.0",
"cookie-parser": "^1.4.6",
"date-fns": "^3.6.0",
"dotenv": "^16.4.5",
"drizzle-kit": "^0.22.8",
"drizzle-orm": "^0.31.2",
Expand Down
4 changes: 4 additions & 0 deletions apps/api/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,12 @@ import { EmailModule } from "./common/emails/emails.module";
import { JwtAuthGuard } from "./common/guards/jwt-auth.guard";
import { StagingGuard } from "./common/guards/staging.guard";
import { CoursesModule } from "./courses/courses.module";
import { EventsModule } from "./events/events.module";
import { S3Module } from "./file/s3.module";
import { HealthModule } from "./health/health.module";
import { LessonsModule } from "./lessons/lessons.module";
import { QuestionsModule } from "./questions/questions.module";
import { StatisticsModule } from "./statistics/statistics.module";
import * as schema from "./storage/schema";
import { StripeModule } from "./stripe/stripe.module";
import { StudentCompletedLessonItemsModule } from "./studentCompletedLessonItem/studentCompletedLessonItems.module";
Expand Down Expand Up @@ -73,6 +75,8 @@ import { TestConfigModule } from "./test-config/test-config.module";
StudentCompletedLessonItemsModule,
S3Module,
StripeModule,
EventsModule,
StatisticsModule,
],
controllers: [],
providers: [
Expand Down
5 changes: 5 additions & 0 deletions apps/api/src/auth/api/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
UnauthorizedException,
UseGuards,
} from "@nestjs/common";
import { EventBus } from "@nestjs/cqrs";
import { AuthGuard } from "@nestjs/passport";
import { type Request, Response } from "express";
import { Validate } from "nestjs-typebox";
Expand All @@ -18,6 +19,7 @@ import { Roles } from "src/common/decorators/roles.decorator";
import { CurrentUser } from "src/common/decorators/user.decorator";
import { RefreshTokenGuard } from "src/common/guards/refresh-token.guard";
import { commonUserSchema } from "src/common/schemas/common-user.schema";
import { UserActivityEvent } from "src/events";
import { USER_ROLES } from "src/users/schemas/user-roles";

import { AuthService } from "../auth.service";
Expand All @@ -39,6 +41,7 @@ export class AuthController {
constructor(
private readonly authService: AuthService,
private readonly tokenService: TokenService,
private readonly eventBus: EventBus,
) {}

@Public()
Expand Down Expand Up @@ -115,6 +118,8 @@ export class AuthController {
): Promise<BaseResponse<Static<typeof commonUserSchema>>> {
const account = await this.authService.currentUser(currentUserId);

this.eventBus.publish(new UserActivityEvent(currentUserId, "LOGIN"));

return new BaseResponse(account);
}

Expand Down
3 changes: 2 additions & 1 deletion apps/api/src/auth/auth.service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Inject,
Injectable,
NotFoundException,
Expand Down Expand Up @@ -120,7 +121,7 @@ export class AuthService {
const tokens = await this.getTokens(user);
return tokens;
} catch (error) {
throw new UnauthorizedException("Invalid refresh token");
throw new ForbiddenException("Invalid refresh token");
}
}

Expand Down
3 changes: 2 additions & 1 deletion apps/api/src/common/guards/refresh-token.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
type CanActivate,
type ExecutionContext,
UnauthorizedException,
ForbiddenException,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { JwtService } from "@nestjs/jwt";
Expand Down Expand Up @@ -34,7 +35,7 @@ export class RefreshTokenGuard implements CanActivate {

return true;
} catch {
throw new UnauthorizedException("Invalid refresh token");
throw new ForbiddenException("Invalid refresh token");
}
}
}
3 changes: 3 additions & 0 deletions apps/api/src/common/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export type ActivityHistory = {
[date: string]: boolean;
};
13 changes: 13 additions & 0 deletions apps/api/src/events/course/course-activity.event.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export class CourseStartedEvent {
constructor(
public readonly userId: string,
public readonly courseId: string,
) {}
}

export class CourseCompletedEvent {
constructor(
public readonly userId: string,
public readonly courseId: string,
) {}
}
9 changes: 9 additions & 0 deletions apps/api/src/events/events.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Global, Module } from "@nestjs/common";
import { CqrsModule } from "@nestjs/cqrs";

@Global()
@Module({
imports: [CqrsModule],
exports: [CqrsModule],
})
export class EventsModule {}
4 changes: 4 additions & 0 deletions apps/api/src/events/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export * from "./course/course-activity.event";
export * from "./lesson/lesson-completed.event";
export * from "./quiz/quiz-completed.event";
export * from "./user/user-activity.event";
7 changes: 7 additions & 0 deletions apps/api/src/events/lesson/lesson-completed.event.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export class LessonCompletedEvent {
constructor(
public readonly userId: string,
public readonly courseId: string,
public readonly lessonId: string,
) {}
}
10 changes: 10 additions & 0 deletions apps/api/src/events/quiz/quiz-completed.event.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export class QuizCompletedEvent {
constructor(
public readonly userId: string,
public readonly courseId: string,
public readonly lessonId: string,
public readonly correctAnswers: number,
public readonly wrongAnswers: number,
public readonly score: number,
) {}
}
9 changes: 9 additions & 0 deletions apps/api/src/events/user/user-activity.event.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export type UserActivityType = "LOGIN" | "LESSON_PROGRESS" | "COURSE_PROGRESS";

export class UserActivityEvent {
constructor(
public readonly userId: string,
public readonly activityType: UserActivityType,
public readonly metadata?: Record<string, any>,
) {}
}
21 changes: 21 additions & 0 deletions apps/api/src/lessons/lessons.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ import {
NotFoundException,
UnauthorizedException,
} from "@nestjs/common";
import { EventBus } from "@nestjs/cqrs";
import { isNull } from "lodash";
import { match, P } from "ts-pattern";

import { DatabasePg } from "src/common";
import { QuizCompletedEvent } from "src/events";
import { S3Service } from "src/file/s3.service";
import { LessonProgress } from "src/lessons/schemas/lesson.types";

Expand All @@ -30,6 +32,7 @@ export class LessonsService {
@Inject("DB") private readonly db: DatabasePg,
private readonly s3Service: S3Service,
private readonly lessonsRepository: LessonsRepository,
private readonly eventBus: EventBus,
) {}

async getLesson(id: UUIDType, courseId: UUIDType, userId: UUIDType, isAdmin?: boolean) {
Expand Down Expand Up @@ -163,6 +166,10 @@ export class LessonsService {
userId,
true,
);

let correctAnswers = 0;
let wrongAnswers = 0;

try {
await this.db.transaction(async (trx) => {
await Promise.all(
Expand Down Expand Up @@ -228,9 +235,23 @@ export class LessonsService {
passQuestion,
trx,
);

if (passQuestion) {
correctAnswers++;
} else {
wrongAnswers++;
}
}),
);
});

const totalQuestions = questionLessonItems.length;
const score = Math.round((correctAnswers / totalQuestions) * 100);

this.eventBus.publish(
new QuizCompletedEvent(userId, courseId, lessonId, correctAnswers, wrongAnswers, score),
);

return true;
} catch (error) {
console.log("error", error);
Expand Down
25 changes: 25 additions & 0 deletions apps/api/src/statistics/api/statistics.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { Controller, Get } from "@nestjs/common";
import { Validate } from "nestjs-typebox";

import { baseResponse, BaseResponse, UUIDType } from "src/common";

import { CurrentUser } from "../../common/decorators/user.decorator";
import { UserStatsSchema } from "../schemas/userStats.schema";
import { StatisticsService } from "../statistics.service";

import type { UserStats } from "../schemas/userStats.schema";

@Controller("statistics")
export class StatisticsController {
constructor(private statisticsService: StatisticsService) {}

@Get()
@Validate({
response: baseResponse(UserStatsSchema),
})
async getUserStatistics(
@CurrentUser("userId") currentUserId: UUIDType,
): Promise<BaseResponse<UserStats>> {
return new BaseResponse(await this.statisticsService.getUserStats(currentUserId));
}
}
55 changes: 55 additions & 0 deletions apps/api/src/statistics/handlers/statistics.handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { Injectable } from "@nestjs/common";
import { EventsHandler } from "@nestjs/cqrs";
import { match } from "ts-pattern";

import { QuizCompletedEvent, UserActivityEvent, CourseStartedEvent } from "src/events";

import { StatisticsRepository } from "../repositories/statistics.repository";

import type { IEventHandler } from "@nestjs/cqrs";

type StatisticsEvent = QuizCompletedEvent | UserActivityEvent | CourseStartedEvent;

@Injectable()
@EventsHandler(QuizCompletedEvent, UserActivityEvent, CourseStartedEvent)
export class StatisticsHandler implements IEventHandler<QuizCompletedEvent | UserActivityEvent> {
constructor(private readonly statisticsRepository: StatisticsRepository) {}

async handle(event: StatisticsEvent) {
try {
match(event)
.when(
(e): e is QuizCompletedEvent => e instanceof QuizCompletedEvent,
async (quizEvent) => {
await this.handleQuizCompleted(quizEvent);
},
)
.when(
(e): e is UserActivityEvent => e instanceof UserActivityEvent,
async (activityEvent) => {
await this.handleUserActivity(activityEvent);
},
)
.otherwise(() => {
throw new Error("Unknown event type");
});
} catch (error) {
console.error("Error handling event:", error);
}
}

private async handleQuizCompleted(event: QuizCompletedEvent) {
await this.statisticsRepository.createQuizAttempt({
userId: event.userId,
courseId: event.courseId,
lessonId: event.lessonId,
correctAnswers: event.correctAnswers,
wrongAnswers: event.wrongAnswers,
score: event.score,
});
}

private async handleUserActivity(event: UserActivityEvent) {
await this.statisticsRepository.updateUserActivity(event.userId);
}
}
Loading

0 comments on commit 34d6b10

Please sign in to comment.