diff --git a/apps/api/src/courses/courses.service.ts b/apps/api/src/courses/courses.service.ts index ca7e80560..164e33dc6 100644 --- a/apps/api/src/courses/courses.service.ts +++ b/apps/api/src/courses/courses.service.ts @@ -315,6 +315,7 @@ export class CoursesService { if (!course) throw new NotFoundException("Course not found"); + // TODO: to remove and start use getLessonsDetails form lessonsRepository const courseLessonList = await this.db .select({ id: lessons.id, diff --git a/apps/api/src/lessons/repositories/lessons.repository.ts b/apps/api/src/lessons/repositories/lessons.repository.ts index 0bf84ff19..bce4d84b5 100644 --- a/apps/api/src/lessons/repositories/lessons.repository.ts +++ b/apps/api/src/lessons/repositories/lessons.repository.ts @@ -1,5 +1,5 @@ import { Inject, Injectable } from "@nestjs/common"; -import { and, count, eq, inArray, sql } from "drizzle-orm"; +import { and, count, desc, eq, inArray, isNotNull, sql } from "drizzle-orm"; import { DatabasePg, type UUIDType } from "src/common"; import { STATES } from "src/common/states"; @@ -19,6 +19,7 @@ import { } from "src/storage/schema"; import { LESSON_TYPE } from "../lesson.type"; +import { LessonProgress } from "../schemas/lesson.types"; import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"; import type * as schema from "src/storage/schema"; @@ -36,11 +37,11 @@ export class LessonsRepository { imageUrl: sql`${lessons.imageUrl}`, type: sql`${lessons.type}`, isSubmitted: sql` - CASE + CASE WHEN ${studentLessonsProgress.quizCompleted} IS NOT NULL THEN ${studentLessonsProgress.quizCompleted} - ELSE FALSE - END - `, + ELSE FALSE + END + `, isFree: courseLessons.isFree, enrolled: sql`CASE WHEN ${studentCourses.id} IS NOT NULL THEN true ELSE false END`, }) @@ -85,12 +86,12 @@ export class LessonsRepository { questionData: questions, displayOrder: lessonItems.displayOrder, passQuestion: sql` - CASE + CASE WHEN ${lessonType} = ${LESSON_TYPE.quiz.key} AND ${lessonRated} THEN - ${studentQuestionAnswers.isCorrect} - ELSE null - END - `, + ${studentQuestionAnswers.isCorrect} + ELSE null + END + `, }) .from(lessonItems) .leftJoin( @@ -169,27 +170,27 @@ export class LessonsRepository { optionText: questionAnswerOptions.optionText, position: questionAnswerOptions.position, isStudentAnswer: sql` - CASE + CASE WHEN ${studentQuestionAnswers.id} IS NULL THEN NULL - WHEN ${studentQuestionAnswers.answer}->>CAST(${questionAnswerOptions.position} AS text) = ${questionAnswerOptions.optionText} AND - ${questions.questionType} IN (${QUESTION_TYPE.fill_in_the_blanks_dnd.key}, ${QUESTION_TYPE.fill_in_the_blanks_text.key}) - THEN TRUE - WHEN EXISTS ( - SELECT 1 - FROM jsonb_object_keys(${studentQuestionAnswers.answer}) AS key - WHERE ${studentQuestionAnswers.answer}->key = to_jsonb(${questionAnswerOptions.optionText}) - ) AND ${questions.questionType} NOT IN (${QUESTION_TYPE.fill_in_the_blanks_dnd.key}, ${QUESTION_TYPE.fill_in_the_blanks_text.key}) - THEN TRUE - ELSE FALSE - END - `, + WHEN ${studentQuestionAnswers.answer}->>CAST(${questionAnswerOptions.position} AS text) = ${questionAnswerOptions.optionText} AND + ${questions.questionType} IN (${QUESTION_TYPE.fill_in_the_blanks_dnd.key}, ${QUESTION_TYPE.fill_in_the_blanks_text.key}) + THEN TRUE + WHEN EXISTS ( + SELECT 1 + FROM jsonb_object_keys(${studentQuestionAnswers.answer}) AS key + WHERE ${studentQuestionAnswers.answer}->key = to_jsonb(${questionAnswerOptions.optionText}) + ) AND ${questions.questionType} NOT IN (${QUESTION_TYPE.fill_in_the_blanks_dnd.key}, ${QUESTION_TYPE.fill_in_the_blanks_text.key}) + THEN TRUE + ELSE FALSE + END + `, isCorrect: sql` - CASE + CASE WHEN ${lessonType} = 'quiz' AND ${lessonRated} THEN - ${questionAnswerOptions.isCorrect} - ELSE NULL - END - `, + ${questionAnswerOptions.isCorrect} + ELSE NULL + END + `, }) .from(questionAnswerOptions) .leftJoin(questions, eq(questionAnswerOptions.questionId, questions.id)) @@ -256,11 +257,11 @@ export class LessonsRepository { const [lessonProgress] = await this.db .select({ quizCompleted: sql` - CASE + CASE WHEN ${studentLessonsProgress.quizCompleted} THEN - ${studentLessonsProgress.quizCompleted} - ELSE false - END`, + ${studentLessonsProgress.quizCompleted} + ELSE false + END`, lessonItemCount: studentLessonsProgress.lessonItemCount, completedLessonItemCount: studentLessonsProgress.completedLessonItemCount, quizScore: studentLessonsProgress.quizScore, @@ -328,6 +329,78 @@ export class LessonsRepository { return quizScore.quizScore; } + async getLessonsDetails(userId: UUIDType, courseId: UUIDType, lessonId?: UUIDType) { + const conditions = [ + eq(courseLessons.courseId, courseId), + eq(lessons.archived, false), + eq(lessons.state, STATES.published), + isNotNull(lessons.id), + isNotNull(lessons.title), + isNotNull(lessons.description), + isNotNull(lessons.imageUrl), + ]; + if (lessonId) conditions.push(eq(lessons.id, lessonId)); + + return await this.db + .select({ + id: lessons.id, + title: lessons.title, + type: lessons.type, + isSubmitted: sql` + EXISTS ( + SELECT 1 + FROM ${studentLessonsProgress} + WHERE ${studentLessonsProgress.lessonId} = ${lessons.id} + AND ${studentLessonsProgress.courseId} = ${courseId} + AND ${studentLessonsProgress.studentId} = ${userId} + AND ${studentLessonsProgress.quizCompleted} + )::BOOLEAN`, + description: sql`${lessons.description}`, + imageUrl: sql`${lessons.imageUrl}`, + itemsCount: sql` + (SELECT COUNT(*) + FROM ${lessonItems} + WHERE ${lessonItems.lessonId} = ${lessons.id} + AND ${lessonItems.lessonItemType} != 'text_block')::INTEGER`, + itemsCompletedCount: sql` + (SELECT COUNT(*) + FROM ${studentCompletedLessonItems} + WHERE ${studentCompletedLessonItems.lessonId} = ${lessons.id} + AND ${studentCompletedLessonItems.courseId} = ${courseId} + AND ${studentCompletedLessonItems.studentId} = ${userId})::INTEGER`, + lessonProgress: sql<"completed" | "in_progress" | "not_started">` + (CASE + WHEN ( + SELECT COUNT(*) + FROM ${lessonItems} + WHERE ${lessonItems.lessonId} = ${lessons.id} + AND ${lessonItems.lessonItemType} != 'text_block' + ) = ( + SELECT COUNT(*) + FROM ${studentCompletedLessonItems} + WHERE ${studentCompletedLessonItems.lessonId} = ${lessons.id} + AND ${studentCompletedLessonItems.courseId} = ${courseId} + AND ${studentCompletedLessonItems.studentId} = ${userId} + ) + THEN ${LessonProgress.completed} + WHEN ( + SELECT COUNT(*) + FROM ${studentCompletedLessonItems} + WHERE ${studentCompletedLessonItems.lessonId} = ${lessons.id} + AND ${studentCompletedLessonItems.courseId} = ${courseId} + AND ${studentCompletedLessonItems.studentId} = ${userId} + ) > 0 + THEN ${LessonProgress.inProgress} + ELSE ${LessonProgress.notStarted} + END) + `, + isFree: courseLessons.isFree, + }) + .from(courseLessons) + .innerJoin(lessons, eq(courseLessons.lessonId, lessons.id)) + .where(and(...conditions)); + } + async completeQuiz( courseId: UUIDType, lessonId: UUIDType, @@ -452,6 +525,21 @@ export class LessonsRepository { .limit(1); } + async getLastLessonItemForUser(userId: UUIDType) { + const [lastLessonItem] = await this.db + .select({ + id: studentCompletedLessonItems.lessonItemId, + lessonId: studentCompletedLessonItems.lessonId, + courseId: studentCompletedLessonItems.courseId, + }) + .from(studentCompletedLessonItems) + .where(eq(studentCompletedLessonItems.studentId, userId)) + .orderBy(desc(studentCompletedLessonItems.updatedAt)) + .limit(1); + + return lastLessonItem; + } + async removeQuestionsAnswer( courseId: UUIDType, lessonId: UUIDType, diff --git a/apps/api/src/statistics/api/statistics.controller.ts b/apps/api/src/statistics/api/statistics.controller.ts index 05abd3749..6eb60d3a2 100644 --- a/apps/api/src/statistics/api/statistics.controller.ts +++ b/apps/api/src/statistics/api/statistics.controller.ts @@ -1,16 +1,25 @@ import { Controller, Get } from "@nestjs/common"; +import { Validate } from "nestjs-typebox"; -import { BaseResponse, UUIDType } from "src/common"; +import { baseResponse, BaseResponse, UUIDType } from "src/common"; import { CurrentUser } from "src/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() - async getUserStatistics(@CurrentUser("userId") currentUserId: UUIDType) { + @Validate({ + response: baseResponse(UserStatsSchema), + }) + async getUserStatistics( + @CurrentUser("userId") currentUserId: UUIDType, + ): Promise> { return new BaseResponse(await this.statisticsService.getUserStats(currentUserId)); } } diff --git a/apps/api/src/statistics/handlers/statistics.handler.ts b/apps/api/src/statistics/handlers/statistics.handler.ts index 07805b94a..9aedc8512 100644 --- a/apps/api/src/statistics/handlers/statistics.handler.ts +++ b/apps/api/src/statistics/handlers/statistics.handler.ts @@ -2,9 +2,10 @@ import { Injectable } from "@nestjs/common"; import { EventsHandler } from "@nestjs/cqrs"; import { match } from "ts-pattern"; -import { QuizCompletedEvent, UserActivityEvent, CourseStartedEvent } from "src/events"; +import { CourseStartedEvent, QuizCompletedEvent, UserActivityEvent } from "src/events"; +import { StatisticsRepository } from "src/statistics/repositories/statistics.repository"; -import { StatisticsRepository } from "../repositories/statistics.repository"; +import { StatisticsService } from "../statistics.service"; import type { IEventHandler } from "@nestjs/cqrs"; @@ -13,7 +14,10 @@ type StatisticsEvent = QuizCompletedEvent | UserActivityEvent | CourseStartedEve @Injectable() @EventsHandler(QuizCompletedEvent, UserActivityEvent, CourseStartedEvent) export class StatisticsHandler implements IEventHandler { - constructor(private readonly statisticsRepository: StatisticsRepository) {} + constructor( + private readonly statisticsRepository: StatisticsRepository, + private readonly statisticsService: StatisticsService, + ) {} async handle(event: StatisticsEvent) { try { @@ -50,6 +54,6 @@ export class StatisticsHandler implements IEventHandler`count(*)`, - totalCorrectAnswers: sql`coalesce(sum(${quizAttempts.correctAnswers}), 0)`, - totalWrongAnswers: sql`coalesce(sum(${quizAttempts.wrongAnswers}), 0)`, - totalQuestions: sql`coalesce(sum(${quizAttempts.correctAnswers} + ${quizAttempts.wrongAnswers}), 0)`, - averageScore: sql`coalesce(round(avg(${quizAttempts.score}), 2), 0)`, - uniqueQuizzesTaken: sql`count(distinct ${quizAttempts.lessonId})`, + totalAttempts: sql`count(*) :: INTEGER`, + totalCorrectAnswers: sql`coalesce(sum(${quizAttempts.correctAnswers}), 0) :: INTEGER`, + totalWrongAnswers: sql`coalesce(sum(${quizAttempts.wrongAnswers}), 0) :: INTEGER`, + totalQuestions: sql`coalesce(sum(${quizAttempts.correctAnswers} + ${quizAttempts.wrongAnswers}), 0) :: INTEGER`, + averageScore: sql`coalesce(round(avg(${quizAttempts.score}), 2), 0) :: INTEGER`, + uniqueQuizzesTaken: sql`coalesce(count(distinct ${quizAttempts.lessonId}), 0) :: INTEGER`, }) .from(quizAttempts) - .where(eq(quizAttempts.userId, userId)) - .groupBy(quizAttempts.userId); + .where(eq(quizAttempts.userId, userId)); - const quizStats = quizStatsResult || { - totalAttempts: 0, - totalCorrectAnswers: 0, - totalWrongAnswers: 0, - totalQuestions: 0, - averageScore: 0, - uniqueQuizzesTaken: 0, - }; + return quizStatsResult; + } - const courseStatsResult = await this.db + async getCoursesStatsByMonth(userId: string) { + return this.db .select({ month: sql`to_char(date_trunc('month', ${studentCourses.createdAt}), 'YYYY-MM')`, - started: sql`count(*)`, - completed: sql`count(case when ${studentCourses.state} = 'completed' then 1 end)`, + started: sql`count(*) :: INTEGER`, + completed: sql`count(case when ${studentCourses.state} = 'completed' then 1 end) :: INTEGER`, completionRate: sql` coalesce( round( @@ -67,7 +49,7 @@ export class StatisticsRepository { 2 ), 0 - ) + ) :: INTEGER `, }) .from(studentCourses) @@ -79,40 +61,14 @@ export class StatisticsRepository { ) .groupBy(sql`date_trunc('month', ${studentCourses.createdAt})`) .orderBy(sql`date_trunc('month', ${studentCourses.createdAt})`); + } - const [courseStats] = await this.db - .select({ - started: sql`count(*)::INTEGER`, - completed: sql`count(case when ${studentCourses.state} = 'completed' then 1 end)::INTEGER`, - completionRate: sql` - coalesce( - round( - (count(case when ${studentCourses.state} = 'completed' then 1 end)::numeric / - nullif(count(*)::numeric, 0)) * 100, - 2 - ), - 0 - )::INTEGER - `, - }) - .from(studentCourses) - .where( - and( - eq(studentCourses.studentId, userId), - sql`${studentCourses.createdAt} >= date_trunc('month', current_date) - interval '11 months'`, - ), - ); - - const [activityStats] = await this.db - .select() - .from(userStatistics) - .where(eq(userStatistics.userId, userId)); - - const lessonStatsResult = await this.db + async getLessonsStatsByMonth(userId: string) { + return this.db .select({ month: sql`to_char(date_trunc('month', ${studentLessonsProgress.createdAt}), 'YYYY-MM')`, - started: sql`count(distinct ${studentLessonsProgress.lessonId})`, - completed: sql`count(case when ${studentLessonsProgress.completedLessonItemCount} = ${studentLessonsProgress.lessonItemCount} then 1 end)`, + started: sql`count(distinct ${studentLessonsProgress.lessonId}) :: INTEGER`, + completed: sql`count(case when ${studentLessonsProgress.completedLessonItemCount} = ${studentLessonsProgress.lessonItemCount} then 1 end) :: INTEGER`, completionRate: sql` coalesce( round( @@ -121,7 +77,7 @@ export class StatisticsRepository { 2 ), 0 - ) + ) :: INTEGER `, }) .from(studentLessonsProgress) @@ -133,135 +89,19 @@ export class StatisticsRepository { ) .groupBy(sql`date_trunc('month', ${studentLessonsProgress.createdAt})`) .orderBy(sql`date_trunc('month', ${studentLessonsProgress.createdAt})`); + } - const [lessonStats] = await this.db - .select({ - started: sql`count(distinct ${studentLessonsProgress.lessonId})::INTEGER`, - completed: sql`count(case when ${studentLessonsProgress.completedLessonItemCount} = ${studentLessonsProgress.lessonItemCount} then 1 end)::INTEGER`, - completionRate: sql` - coalesce( - round( - (count(case when ${studentLessonsProgress.completedLessonItemCount} = ${studentLessonsProgress.lessonItemCount} then 1 end)::numeric / - nullif(count(distinct ${studentLessonsProgress.lessonId})::numeric, 0)) * 100, - 2 - ), - 0 - )::INTEGER - `, - }) - .from(studentLessonsProgress) - .where( - and( - eq(studentLessonsProgress.studentId, userId), - sql`${studentLessonsProgress.createdAt} >= date_trunc('month', current_date) - interval '11 months'`, - ), - ); - - const [lastLessonItem] = await this.db - .select() - .from(studentCompletedLessonItems) - .where(and(eq(studentCompletedLessonItems.studentId, userId))) - .orderBy(desc(studentCompletedLessonItems.updatedAt)) - .limit(1); - - const lastLessonDetails = await this.lessonsRepository.getLessonForUser( - lastLessonItem.courseId, - lastLessonItem.lessonId, - userId, - ); - - const [lastLesson] = await this.db + async getActivityStats(userId: string) { + const [result] = await this.db .select({ - // TODO: Code below needs https://github.com/wielopolski love - lessonProgress: sql` - (CASE - WHEN ( - SELECT COUNT(*) - FROM ${lessonItems} - WHERE ${lessonItems.lessonId} = ${lastLessonItem.lessonId} - AND ${lessonItems.lessonItemType} != 'text_block' - ) = ( - SELECT COUNT(*) - FROM ${studentCompletedLessonItems} - WHERE ${studentCompletedLessonItems.lessonId} = ${lastLessonItem.lessonId} - AND ${studentCompletedLessonItems.courseId} = ${lastLessonItem.courseId} - AND ${studentCompletedLessonItems.studentId} = ${userId} - ) AND ( - SELECT COUNT(*) - FROM ${lessonItems} - WHERE ${lessonItems.lessonId} = ${lastLessonItem.lessonId} - AND ${lessonItems.lessonItemType} != 'text_block' - ) > 0 - THEN ${LessonProgress.completed} - WHEN ( - SELECT COUNT(*) - FROM ${studentCompletedLessonItems} - WHERE ${studentCompletedLessonItems.lessonId} = ${lastLessonItem.lessonId} - AND ${studentCompletedLessonItems.courseId} = ${lastLessonItem.courseId} - AND ${studentCompletedLessonItems.studentId} = ${userId} - ) > 0 - THEN ${LessonProgress.inProgress} - ELSE ${LessonProgress.notStarted} - END) - `, - itemsCount: sql` - (SELECT COUNT(*) - FROM ${lessonItems} - WHERE ${lessonItems.lessonId} = ${lastLessonItem.lessonId} - AND ${lessonItems.lessonItemType} != 'text_block')::INTEGER`, - itemsCompletedCount: sql` - (SELECT COUNT(*) - FROM ${studentCompletedLessonItems} - WHERE ${studentCompletedLessonItems.lessonId} = ${lastLessonItem.lessonId} - AND ${studentCompletedLessonItems.courseId} = ${lastLessonItem.courseId} - AND ${studentCompletedLessonItems.studentId} = ${userId})::INTEGER - `, + currentStreak: userStatistics.currentStreak, + longestStreak: userStatistics.longestStreak, + lastActivityDate: userStatistics.lastActivityDate, + activityHistory: userStatistics.activityHistory, }) - .from(lessons) - .where(and(eq(lessons.id, lastLessonItem.lessonId))) - .leftJoin( - studentLessonsProgress, - and( - eq(studentLessonsProgress.studentId, userId), - eq(studentLessonsProgress.lessonId, lastLessonItem.lessonId), - eq(studentLessonsProgress.courseId, lastLessonItem.courseId), - ), - ) - .leftJoin(lessonItems, eq(studentLessonsProgress.lessonId, lessonItems.lessonId)) - .leftJoin( - studentCompletedLessonItems, - and( - eq(studentLessonsProgress.lessonId, studentCompletedLessonItems.lessonId), - eq(studentCompletedLessonItems.courseId, studentLessonsProgress.courseId), - ), - ); - - console.log(lastLesson); - return { - quizzes: { - totalAttempts: Number(quizStats.totalAttempts), - totalCorrectAnswers: Number(quizStats.totalCorrectAnswers), - totalWrongAnswers: Number(quizStats.totalWrongAnswers), - totalQuestions: Number(quizStats.totalQuestions), - averageScore: Number(quizStats.averageScore), - uniqueQuizzesTaken: Number(quizStats.uniqueQuizzesTaken), - }, - courses: this.formatCourseStats(courseStatsResult), - streak: { - current: Number(activityStats?.currentStreak) || 0, - longest: Number(activityStats?.longestStreak) || 0, - activityHistory: activityStats?.activityHistory || {}, - }, - lessons: this.formatLessonStats(lessonStatsResult), - averageStats: { - lessonStats, - courseStats, - }, - lastLesson: { - ...lastLessonDetails, - ...lastLesson, - }, - }; + .from(userStatistics) + .where(eq(userStatistics.userId, userId)); + return result; } async createQuizAttempt(data: { @@ -275,129 +115,18 @@ export class StatisticsRepository { return this.db.insert(quizAttempts).values(data); } - async updateUserActivity(userId: string) { - const today = startOfDay(new Date()); - const formatedTodayDate = format(today, "yyyy-MM-dd"); - - const [currentStats] = await this.db - .select({ - currentStreak: userStatistics.currentStreak, - longestStreak: userStatistics.longestStreak, - lastActivityDate: userStatistics.lastActivityDate, - activityHistory: userStatistics.activityHistory, - }) - .from(userStatistics) - .where(eq(userStatistics.userId, userId)); - - const lastActivityDate = currentStats?.lastActivityDate - ? startOfDay(new Date(currentStats.lastActivityDate)) - : null; - - const newCurrentStreak = (() => { - if (!lastActivityDate) return 1; - - const daysDiff = differenceInDays(today, lastActivityDate); - - if (daysDiff === 0) return currentStats?.currentStreak ?? 1; - if (daysDiff === 1) return (currentStats?.currentStreak ?? 0) + 1; - return 1; - })(); - - const newLongestStreak = Math.max(newCurrentStreak, currentStats?.longestStreak ?? 0); - - const isUserLoggedInToday = currentStats?.activityHistory?.[formatedTodayDate] ?? false; - if (isUserLoggedInToday) { - return; - } - - const dateRange = lastActivityDate - ? eachDayOfInterval({ start: lastActivityDate, end: today }) - : [today]; - - const newActivityHistory = dateRange.reduce( - (acc, date) => { - const dateStr = format(date, "yyyy-MM-dd"); - if (!acc[dateStr]) { - acc[dateStr] = dateStr === formatedTodayDate; - } - return acc; - }, - { ...(currentStats?.activityHistory ?? {}) }, - ); - + async upsertUserStatistic(userId: string, upsertUserStatistic: UserStatistic) { await this.db .insert(userStatistics) .values({ userId, - currentStreak: newCurrentStreak, - longestStreak: newLongestStreak, - lastActivityDate: today, - activityHistory: newActivityHistory, + ...upsertUserStatistic, }) .onConflictDoUpdate({ target: userStatistics.userId, set: { - currentStreak: newCurrentStreak, - longestStreak: newLongestStreak, - lastActivityDate: today, - activityHistory: newActivityHistory, + ...upsertUserStatistic, }, }); } - - private formatCourseStats = (courseStats: Stats[]) => { - const monthlyStats: { [key: string]: Omit } = {}; - - const currentDate = new Date(); - for (let index = 11; index >= 0; index--) { - const month = format( - new Date(currentDate.getFullYear(), currentDate.getMonth() - index, 1), - "MMMM", - ); - monthlyStats[month] = { - started: 0, - completed: 0, - completionRate: 0, - }; - } - - for (const stat of courseStats) { - const month = format(new Date(stat.month), "MMMM"); - monthlyStats[month] = { - started: Number(stat.started), - completed: Number(stat.completed), - completionRate: Number(stat.completionRate), - }; - } - - return monthlyStats; - }; - - private formatLessonStats = (lessonStats: Stats[]) => { - const monthlyStats: { [key: string]: Omit } = {}; - - const currentDate = new Date(); - for (let index = 11; index >= 0; index--) { - const month = format( - new Date(currentDate.getFullYear(), currentDate.getMonth() - index, 1), - "MMMM", - ); - monthlyStats[month] = { - started: 0, - completed: 0, - completionRate: 0, - }; - } - - for (const stat of lessonStats) { - const month = format(new Date(stat.month), "MMMM"); - monthlyStats[month] = { - started: Number(stat.started), - completed: Number(stat.completed), - completionRate: Number(stat.completionRate), - }; - } - - return monthlyStats; - }; } diff --git a/apps/api/src/statistics/schemas/userStats.schema.ts b/apps/api/src/statistics/schemas/userStats.schema.ts index 61c1e5d7e..f2ece3838 100644 --- a/apps/api/src/statistics/schemas/userStats.schema.ts +++ b/apps/api/src/statistics/schemas/userStats.schema.ts @@ -1,5 +1,7 @@ import { Type } from "@sinclair/typebox"; +import { lessonSchema } from "src/lessons/schemas/lesson.schema"; + import type { Static } from "@sinclair/typebox"; export const ActivityHistorySchema = Type.Record(Type.String(), Type.Boolean()); @@ -19,6 +21,11 @@ const MonthlyStatsSchema = Type.Object({ completionRate: Type.Number(), }); +const StatsByMonthSchema = Type.Object({ + month: Type.String(), + ...MonthlyStatsSchema.properties, +}); + export const CourseStatsSchema = Type.Record(Type.String(), MonthlyStatsSchema); export const LessonsStatsSchema = Type.Record(Type.String(), MonthlyStatsSchema); @@ -34,6 +41,16 @@ export const UserStatsSchema = Type.Object({ courses: CourseStatsSchema, lessons: LessonsStatsSchema, streak: StreakSchema, + lastLesson: lessonSchema, +}); + +const UserStatisticSchema = Type.Object({ + currentStreak: Type.Number(), + longestStreak: Type.Number(), + lastActivityDate: Type.Date(), + activityHistory: ActivityHistorySchema, }); export type UserStats = Static; +export type StatsByMonth = Static; +export type UserStatistic = Static; diff --git a/apps/api/src/statistics/statistics.module.ts b/apps/api/src/statistics/statistics.module.ts index 50fba678c..14ea67abb 100644 --- a/apps/api/src/statistics/statistics.module.ts +++ b/apps/api/src/statistics/statistics.module.ts @@ -2,10 +2,10 @@ import { Module } from "@nestjs/common"; import { CqrsModule } from "@nestjs/cqrs"; import { LessonsRepository } from "src/lessons/repositories/lessons.repository"; +import { StatisticsRepository } from "src/statistics/repositories/statistics.repository"; import { StatisticsController } from "./api/statistics.controller"; import { StatisticsHandler } from "./handlers/statistics.handler"; -import { StatisticsRepository } from "./repositories/statistics.repository"; import { StatisticsService } from "./statistics.service"; @Module({ diff --git a/apps/api/src/statistics/statistics.service.ts b/apps/api/src/statistics/statistics.service.ts index 09a566549..21702c100 100644 --- a/apps/api/src/statistics/statistics.service.ts +++ b/apps/api/src/statistics/statistics.service.ts @@ -1,12 +1,162 @@ import { Injectable } from "@nestjs/common"; +import { differenceInDays, eachDayOfInterval, format, startOfDay } from "date-fns"; -import { StatisticsRepository } from "./repositories/statistics.repository"; +import { LessonsRepository } from "src/lessons/repositories/lessons.repository"; +import { StatisticsRepository } from "src/statistics/repositories/statistics.repository"; + +import type { StatsByMonth } from "./schemas/userStats.schema"; @Injectable() export class StatisticsService { - constructor(private statisticsRepository: StatisticsRepository) {} + constructor( + private statisticsRepository: StatisticsRepository, + private lessonsRepository: LessonsRepository, + ) {} async getUserStats(userId: string) { - return await this.statisticsRepository.getUserStats(userId); + const coursesStatsByMonth: StatsByMonth[] = + await this.statisticsRepository.getCoursesStatsByMonth(userId); + const coursesTotalStats = this.calculateTotalStats(coursesStatsByMonth); + + const lessonsStatsByMonth: StatsByMonth[] = + await this.statisticsRepository.getLessonsStatsByMonth(userId); + const lessonsTotalStats = this.calculateTotalStats(lessonsStatsByMonth); + + const quizStats = await this.statisticsRepository.getQuizStats(userId); + + // TODO: decide what to do when there is no last lesson + const lastLessonItem = await this.lessonsRepository.getLastLessonItemForUser(userId); + const [lastLesson] = await this.lessonsRepository.getLessonsDetails( + userId, + lastLessonItem.courseId, + lastLessonItem.lessonId, + ); + const lastLessonDetails = await this.lessonsRepository.getLessonForUser( + lastLessonItem.courseId, + lastLessonItem.lessonId, + userId, + ); + + const activityStats = await this.statisticsRepository.getActivityStats(userId); + + return { + courses: this.formatStats(coursesStatsByMonth), + lessons: this.formatStats(lessonsStatsByMonth), + quizzes: { + totalAttempts: quizStats.totalAttempts, + totalCorrectAnswers: quizStats.totalCorrectAnswers, + totalWrongAnswers: quizStats.totalWrongAnswers, + totalQuestions: quizStats.totalQuestions, + averageScore: quizStats.averageScore, + uniqueQuizzesTaken: quizStats.uniqueQuizzesTaken, + }, + averageStats: { + lessonStats: lessonsTotalStats, + courseStats: coursesTotalStats, + }, + lastLesson: { + ...lastLesson, + enrolled: lastLessonDetails.enrolled, + }, + streak: { + current: activityStats.currentStreak ?? 0, + longest: activityStats.longestStreak ?? 0, + activityHistory: activityStats?.activityHistory || {}, + }, + }; + } + + async updateUserActivity(userId: string) { + const today = startOfDay(new Date()); + const formatedTodayDate = format(today, "yyyy-MM-dd"); + + const currentStats = await this.statisticsRepository.getActivityStats(userId); + + const lastActivityDate = currentStats?.lastActivityDate + ? startOfDay(new Date(currentStats.lastActivityDate)) + : null; + + const newCurrentStreak = (() => { + if (!lastActivityDate) return 1; + + const daysDiff = differenceInDays(today, lastActivityDate); + + if (daysDiff === 0) return currentStats?.currentStreak ?? 1; + if (daysDiff === 1) return (currentStats?.currentStreak ?? 0) + 1; + return 1; + })(); + + const newLongestStreak = Math.max(newCurrentStreak, currentStats?.longestStreak ?? 0); + + const isUserLoggedInToday = currentStats?.activityHistory?.[formatedTodayDate] ?? false; + if (isUserLoggedInToday) { + return; + } + + const dateRange = lastActivityDate + ? eachDayOfInterval({ start: lastActivityDate, end: today }) + : [today]; + + const newActivityHistory = dateRange.reduce( + (acc, date) => { + const dateStr = format(date, "yyyy-MM-dd"); + if (!acc[dateStr]) { + acc[dateStr] = dateStr === formatedTodayDate; + } + return acc; + }, + { ...(currentStats?.activityHistory ?? {}) }, + ); + + await this.statisticsRepository.upsertUserStatistic(userId, { + currentStreak: newCurrentStreak, + longestStreak: newLongestStreak, + lastActivityDate: today, + activityHistory: newActivityHistory, + }); } + + private calculateTotalStats(coursesStatsByMonth: StatsByMonth[]) { + const totalStats = coursesStatsByMonth.reduce( + (acc, curr) => { + acc.started += curr.started; + acc.completed += curr.completed; + return acc; + }, + { started: 0, completed: 0, completionRate: 0 }, + ); + + totalStats.completionRate = + totalStats.started > 0 ? Math.round((totalStats.completed / totalStats.started) * 100) : 0; + + return totalStats; + } + + private formatStats = (stats: StatsByMonth[]) => { + const monthlyStats: { [key: string]: Omit } = {}; + + const currentDate = new Date(); + for (let index = 11; index >= 0; index--) { + const month = format( + new Date(currentDate.getFullYear(), currentDate.getMonth() - index, 1), + "MMMM", + ); + monthlyStats[month] = { + started: 0, + completed: 0, + completionRate: 0, + }; + } + + for (const stat of stats) { + const month = format(new Date(stat.month), "MMMM"); + monthlyStats[month] = { + started: stat.started, + completed: stat.completed, + completionRate: stat.completionRate, + }; + } + + return monthlyStats; + }; } diff --git a/apps/api/src/swagger/api-schema.json b/apps/api/src/swagger/api-schema.json index a5923f533..e9cd63e00 100644 --- a/apps/api/src/swagger/api-schema.json +++ b/apps/api/src/swagger/api-schema.json @@ -2588,7 +2588,13 @@ "parameters": [], "responses": { "200": { - "description": "" + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetUserStatisticsResponse" + } + } + } } } } @@ -6747,6 +6753,199 @@ "required": [ "data" ] + }, + "GetUserStatisticsResponse": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "quizzes": { + "type": "object", + "properties": { + "totalAttempts": { + "type": "number" + }, + "totalCorrectAnswers": { + "type": "number" + }, + "totalWrongAnswers": { + "type": "number" + }, + "totalQuestions": { + "type": "number" + }, + "averageScore": { + "type": "number" + }, + "uniqueQuizzesTaken": { + "type": "number" + } + }, + "required": [ + "totalAttempts", + "totalCorrectAnswers", + "totalWrongAnswers", + "totalQuestions", + "averageScore", + "uniqueQuizzesTaken" + ] + }, + "courses": { + "type": "object", + "patternProperties": { + "^(.*)$": { + "type": "object", + "properties": { + "started": { + "type": "number" + }, + "completed": { + "type": "number" + }, + "completionRate": { + "type": "number" + } + }, + "required": [ + "started", + "completed", + "completionRate" + ] + } + } + }, + "lessons": { + "type": "object", + "patternProperties": { + "^(.*)$": { + "type": "object", + "properties": { + "started": { + "type": "number" + }, + "completed": { + "type": "number" + }, + "completionRate": { + "type": "number" + } + }, + "required": [ + "started", + "completed", + "completionRate" + ] + } + } + }, + "streak": { + "type": "object", + "properties": { + "current": { + "type": "number" + }, + "longest": { + "type": "number" + }, + "activityHistory": { + "type": "object", + "patternProperties": { + "^(.*)$": { + "type": "boolean" + } + } + } + }, + "required": [ + "current", + "longest", + "activityHistory" + ] + }, + "lastLesson": { + "type": "object", + "properties": { + "id": { + "format": "uuid", + "type": "string" + }, + "title": { + "type": "string" + }, + "imageUrl": { + "type": "string" + }, + "description": { + "type": "string" + }, + "itemsCount": { + "type": "number" + }, + "itemsCompletedCount": { + "type": "number" + }, + "lessonProgress": { + "anyOf": [ + { + "const": "completed", + "type": "string" + }, + { + "const": "in_progress", + "type": "string" + }, + { + "const": "not_started", + "type": "string" + } + ] + }, + "isFree": { + "type": "boolean" + }, + "enrolled": { + "type": "boolean" + }, + "state": { + "type": "string" + }, + "archived": { + "type": "boolean" + }, + "isSubmitted": { + "type": "boolean" + }, + "type": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "quizScore": { + "type": "number" + } + }, + "required": [ + "id", + "title", + "description", + "itemsCount" + ] + } + }, + "required": [ + "quizzes", + "courses", + "lessons", + "streak", + "lastLesson" + ] + } + }, + "required": [ + "data" + ] } } }