diff --git a/apps/api/src/statistics/api/statistics.controller.ts b/apps/api/src/statistics/api/statistics.controller.ts deleted file mode 100644 index 6eb60d3a2..000000000 --- a/apps/api/src/statistics/api/statistics.controller.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Controller, Get } from "@nestjs/common"; -import { Validate } from "nestjs-typebox"; - -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() - @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/repositories/statistics.repository.ts b/apps/api/src/statistics/repositories/statistics.repository.ts index 81c83feaa..3170c5a96 100644 --- a/apps/api/src/statistics/repositories/statistics.repository.ts +++ b/apps/api/src/statistics/repositories/statistics.repository.ts @@ -1,9 +1,11 @@ import { Inject, Injectable } from "@nestjs/common"; -import { eq, sql } from "drizzle-orm"; +import { desc, eq, sql } from "drizzle-orm"; import { DatabasePg } from "src/common"; import { LessonProgress } from "src/lessons/schemas/lesson.types"; import { + courses, + courseStatisticsPerTeacherMaterialized, lessons, quizAttempts, studentCourses, @@ -131,6 +133,19 @@ export class StatisticsRepository { return result; } + async getFiveMostPopularCourses(userId: string) { + return await this.db + .select({ + courseName: courses.title, + studentCount: courseStatisticsPerTeacherMaterialized.studentCount, + }) + .from(courseStatisticsPerTeacherMaterialized) + .innerJoin(courses, eq(courseStatisticsPerTeacherMaterialized.courseId, courses.id)) + .where(eq(courseStatisticsPerTeacherMaterialized.teacherId, userId)) + .orderBy(desc(courseStatisticsPerTeacherMaterialized.studentCount)) + .limit(5); + } + async createQuizAttempt(data: { userId: string; courseId: string; diff --git a/apps/api/src/statistics/schemas/userStats.schema.ts b/apps/api/src/statistics/schemas/userStats.schema.ts index b35a1426b..1b36f3c75 100644 --- a/apps/api/src/statistics/schemas/userStats.schema.ts +++ b/apps/api/src/statistics/schemas/userStats.schema.ts @@ -57,6 +57,15 @@ export const UserStatsSchema = Type.Object({ ]), }); +export const PopularCourseStatsSchema = Type.Object({ + courseName: Type.String(), + studentCount: Type.Number(), +}); + +export const TeacherStatsSchema = Type.Object({ + fiveMostPopularCourses: Type.Array(PopularCourseStatsSchema), +}); + const UserStatisticSchema = Type.Object({ currentStreak: Type.Number(), longestStreak: Type.Number(), @@ -67,3 +76,4 @@ const UserStatisticSchema = Type.Object({ export type UserStats = Static; export type StatsByMonth = Static; export type UserStatistic = Static; +export type TeacherStats = Static; diff --git a/apps/api/src/statistics/statistics.controller.ts b/apps/api/src/statistics/statistics.controller.ts new file mode 100644 index 000000000..55f18bf78 --- /dev/null +++ b/apps/api/src/statistics/statistics.controller.ts @@ -0,0 +1,40 @@ +import { Controller, Get, UseGuards } from "@nestjs/common"; +import { Validate } from "nestjs-typebox"; + +import { baseResponse, BaseResponse, UUIDType } from "src/common"; +import { Roles } from "src/common/decorators/roles.decorator"; +import { CurrentUser } from "src/common/decorators/user.decorator"; +import { RolesGuard } from "src/common/guards/roles.guard"; +import { USER_ROLES } from "src/users/schemas/user-roles"; + +import { TeacherStatsSchema, UserStatsSchema } from "./schemas/userStats.schema"; +import { StatisticsService } from "./statistics.service"; + +import type { TeacherStats, UserStats } from "./schemas/userStats.schema"; + +@UseGuards(RolesGuard) +@Controller("statistics") +export class StatisticsController { + constructor(private statisticsService: StatisticsService) {} + + @Get("user-stats") + @Validate({ + response: baseResponse(UserStatsSchema), + }) + async getUserStatistics( + @CurrentUser("userId") currentUserId: UUIDType, + ): Promise> { + return new BaseResponse(await this.statisticsService.getUserStats(currentUserId)); + } + + @Get("teacher-stats") + @Roles(USER_ROLES.admin, USER_ROLES.tutor) + @Validate({ + response: baseResponse(TeacherStatsSchema), + }) + async getTeacherStats( + @CurrentUser("userId") currentUserId: UUIDType, + ): Promise> { + return new BaseResponse(await this.statisticsService.getTeacherStats(currentUserId)); + } +} diff --git a/apps/api/src/statistics/statistics.module.ts b/apps/api/src/statistics/statistics.module.ts index 14ea67abb..f5d41af0e 100644 --- a/apps/api/src/statistics/statistics.module.ts +++ b/apps/api/src/statistics/statistics.module.ts @@ -4,8 +4,8 @@ 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 { StatisticsController } from "./statistics.controller"; 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 1c74dc5a7..996edc967 100644 --- a/apps/api/src/statistics/statistics.service.ts +++ b/apps/api/src/statistics/statistics.service.ts @@ -52,6 +52,15 @@ export class StatisticsService { }; } + async getTeacherStats(userId: string) { + const fiveMostPopularCourses = + await this.statisticsRepository.getFiveMostPopularCourses(userId); + + return { + fiveMostPopularCourses, + }; + } + async createQuizAttempt(data: { userId: string; courseId: string; diff --git a/apps/api/src/storage/migrations/0012_create_materialized_view_course_statistics_per_teacher.sql b/apps/api/src/storage/migrations/0012_create_materialized_view_course_statistics_per_teacher.sql new file mode 100644 index 000000000..0b4a353ad --- /dev/null +++ b/apps/api/src/storage/migrations/0012_create_materialized_view_course_statistics_per_teacher.sql @@ -0,0 +1,28 @@ +ALTER TABLE "student_lessons_progress" ADD COLUMN "completed_as_freemium" boolean DEFAULT false NOT NULL; + +CREATE MATERIALIZED VIEW + "course_statistics_per_teacher" AS +SELECT + c.author_id AS teacher_id, + c.id AS course_id, + COUNT(sc.id) AS student_count, + SUM( + CASE + WHEN sc.state = 'completed' THEN 1 + ELSE 0 + END + ) AS completed_student_count, + COUNT( + DISTINCT CASE + WHEN slp.completed_as_freemium = true THEN sc.student_id + ELSE NULL + END + ) AS purchased_after_freemium_count +FROM + courses c + LEFT JOIN student_courses sc ON c.id = sc.course_id + LEFT JOIN student_lessons_progress slp ON c.id = slp.course_id + AND slp.completed_at IS NOT NULL +GROUP BY + c.author_id, + c.id; \ No newline at end of file diff --git a/apps/api/src/storage/migrations/meta/0012_snapshot.json b/apps/api/src/storage/migrations/meta/0012_snapshot.json new file mode 100644 index 000000000..e9f00fff4 --- /dev/null +++ b/apps/api/src/storage/migrations/meta/0012_snapshot.json @@ -0,0 +1,2062 @@ +{ + "id": "e8a9b941-a70e-434f-b34e-623c04f805a3", + "prevId": "1baff279-0f61-40d1-90f4-be5e22542735", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.categories": { + "name": "categories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "categories_title_unique": { + "name": "categories_title_unique", + "nullsNotDistinct": false, + "columns": [ + "title" + ] + } + } + }, + "public.conversation_messages": { + "name": "conversation_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "read_at": { + "name": "read_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "conversation_messages_conversation_id_conversations_id_fk": { + "name": "conversation_messages_conversation_id_conversations_id_fk", + "tableFrom": "conversation_messages", + "tableTo": "conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "conversation_messages_author_id_users_id_fk": { + "name": "conversation_messages_author_id_users_id_fk", + "tableFrom": "conversation_messages", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.conversations": { + "name": "conversations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "participant1_id": { + "name": "participant1_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "participant2_id": { + "name": "participant2_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "conversations_participant1_id_users_id_fk": { + "name": "conversations_participant1_id_users_id_fk", + "tableFrom": "conversations", + "tableTo": "users", + "columnsFrom": [ + "participant1_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "conversations_participant2_id_users_id_fk": { + "name": "conversations_participant2_id_users_id_fk", + "tableFrom": "conversations", + "tableTo": "users", + "columnsFrom": [ + "participant2_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.course_lessons": { + "name": "course_lessons", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "course_id": { + "name": "course_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "lesson_id": { + "name": "lesson_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_free": { + "name": "is_free", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "course_lessons_course_id_courses_id_fk": { + "name": "course_lessons_course_id_courses_id_fk", + "tableFrom": "course_lessons", + "tableTo": "courses", + "columnsFrom": [ + "course_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "course_lessons_lesson_id_lessons_id_fk": { + "name": "course_lessons_lesson_id_lessons_id_fk", + "tableFrom": "course_lessons", + "tableTo": "lessons", + "columnsFrom": [ + "lesson_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "course_lessons_course_id_lesson_id_unique": { + "name": "course_lessons_course_id_lesson_id_unique", + "nullsNotDistinct": false, + "columns": [ + "course_id", + "lesson_id" + ] + } + } + }, + "public.courses": { + "name": "courses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar(1000)", + "primaryKey": false, + "notNull": false + }, + "image_url": { + "name": "image_url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "price_in_cents": { + "name": "price_in_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "currency": { + "name": "currency", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'usd'" + }, + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "category_id": { + "name": "category_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "lessons_count": { + "name": "lessons_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "courses_author_id_users_id_fk": { + "name": "courses_author_id_users_id_fk", + "tableFrom": "courses", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "courses_category_id_categories_id_fk": { + "name": "courses_category_id_categories_id_fk", + "tableFrom": "courses", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.create_tokens": { + "name": "create_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "create_token": { + "name": "create_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expiry_date": { + "name": "expiry_date", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "create_tokens_user_id_users_id_fk": { + "name": "create_tokens_user_id_users_id_fk", + "tableFrom": "create_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.credentials": { + "name": "credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "credentials_user_id_users_id_fk": { + "name": "credentials_user_id_users_id_fk", + "tableFrom": "credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.files": { + "name": "files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "title": { + "name": "title", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "files_author_id_users_id_fk": { + "name": "files_author_id_users_id_fk", + "tableFrom": "files", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.lesson_items": { + "name": "lesson_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "lesson_id": { + "name": "lesson_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "lesson_item_id": { + "name": "lesson_item_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "lesson_item_type": { + "name": "lesson_item_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "lesson_items_lesson_id_lessons_id_fk": { + "name": "lesson_items_lesson_id_lessons_id_fk", + "tableFrom": "lesson_items", + "tableTo": "lessons", + "columnsFrom": [ + "lesson_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.lessons": { + "name": "lessons", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "title": { + "name": "title", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar(1000)", + "primaryKey": false, + "notNull": false + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'multimedia'" + }, + "items_count": { + "name": "items_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "lessons_author_id_users_id_fk": { + "name": "lessons_author_id_users_id_fk", + "tableFrom": "lessons", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.notifications": { + "name": "notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "student_id": { + "name": "student_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "read_at": { + "name": "read_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "notifications_student_id_users_id_fk": { + "name": "notifications_student_id_users_id_fk", + "tableFrom": "notifications", + "tableTo": "users", + "columnsFrom": [ + "student_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.question_answer_options": { + "name": "question_answer_options", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "question_id": { + "name": "question_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "option_text": { + "name": "option_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_correct": { + "name": "is_correct", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "question_answer_options_question_id_questions_id_fk": { + "name": "question_answer_options_question_id_questions_id_fk", + "tableFrom": "question_answer_options", + "tableTo": "questions", + "columnsFrom": [ + "question_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.questions": { + "name": "questions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "question_type": { + "name": "question_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "question_body": { + "name": "question_body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "solution_explanation": { + "name": "solution_explanation", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "questions_author_id_users_id_fk": { + "name": "questions_author_id_users_id_fk", + "tableFrom": "questions", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.quiz_attempts": { + "name": "quiz_attempts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "lesson_id": { + "name": "lesson_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "correct_answers": { + "name": "correct_answers", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "wrong_answers": { + "name": "wrong_answers", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "score": { + "name": "score", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "quiz_attempts_user_id_users_id_fk": { + "name": "quiz_attempts_user_id_users_id_fk", + "tableFrom": "quiz_attempts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "quiz_attempts_course_id_courses_id_fk": { + "name": "quiz_attempts_course_id_courses_id_fk", + "tableFrom": "quiz_attempts", + "tableTo": "courses", + "columnsFrom": [ + "course_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "quiz_attempts_lesson_id_lessons_id_fk": { + "name": "quiz_attempts_lesson_id_lessons_id_fk", + "tableFrom": "quiz_attempts", + "tableTo": "lessons", + "columnsFrom": [ + "lesson_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.reset_tokens": { + "name": "reset_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "reset_token": { + "name": "reset_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expiry_date": { + "name": "expiry_date", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "reset_tokens_user_id_users_id_fk": { + "name": "reset_tokens_user_id_users_id_fk", + "tableFrom": "reset_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.student_completed_lesson_items": { + "name": "student_completed_lesson_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "student_id": { + "name": "student_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "lesson_item_id": { + "name": "lesson_item_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "lesson_id": { + "name": "lesson_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "student_completed_lesson_items_student_id_users_id_fk": { + "name": "student_completed_lesson_items_student_id_users_id_fk", + "tableFrom": "student_completed_lesson_items", + "tableTo": "users", + "columnsFrom": [ + "student_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "student_completed_lesson_items_lesson_item_id_lesson_items_id_fk": { + "name": "student_completed_lesson_items_lesson_item_id_lesson_items_id_fk", + "tableFrom": "student_completed_lesson_items", + "tableTo": "lesson_items", + "columnsFrom": [ + "lesson_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "student_completed_lesson_items_lesson_id_lessons_id_fk": { + "name": "student_completed_lesson_items_lesson_id_lessons_id_fk", + "tableFrom": "student_completed_lesson_items", + "tableTo": "lessons", + "columnsFrom": [ + "lesson_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "student_completed_lesson_items_course_id_courses_id_fk": { + "name": "student_completed_lesson_items_course_id_courses_id_fk", + "tableFrom": "student_completed_lesson_items", + "tableTo": "courses", + "columnsFrom": [ + "course_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "student_completed_lesson_items_student_id_lesson_item_id_lesson_id_course_id_unique": { + "name": "student_completed_lesson_items_student_id_lesson_item_id_lesson_id_course_id_unique", + "nullsNotDistinct": false, + "columns": [ + "student_id", + "lesson_item_id", + "lesson_id", + "course_id" + ] + } + } + }, + "public.student_courses": { + "name": "student_courses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "student_id": { + "name": "student_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "finished_lessons_count": { + "name": "finished_lessons_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'not_started'" + }, + "payment_id": { + "name": "payment_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "student_courses_student_id_users_id_fk": { + "name": "student_courses_student_id_users_id_fk", + "tableFrom": "student_courses", + "tableTo": "users", + "columnsFrom": [ + "student_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "student_courses_course_id_courses_id_fk": { + "name": "student_courses_course_id_courses_id_fk", + "tableFrom": "student_courses", + "tableTo": "courses", + "columnsFrom": [ + "course_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "student_courses_student_id_course_id_unique": { + "name": "student_courses_student_id_course_id_unique", + "nullsNotDistinct": false, + "columns": [ + "student_id", + "course_id" + ] + } + } + }, + "public.student_favourited_courses": { + "name": "student_favourited_courses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "student_id": { + "name": "student_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "student_favourited_courses_student_id_users_id_fk": { + "name": "student_favourited_courses_student_id_users_id_fk", + "tableFrom": "student_favourited_courses", + "tableTo": "users", + "columnsFrom": [ + "student_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "student_favourited_courses_course_id_courses_id_fk": { + "name": "student_favourited_courses_course_id_courses_id_fk", + "tableFrom": "student_favourited_courses", + "tableTo": "courses", + "columnsFrom": [ + "course_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "student_favourited_courses_student_id_course_id_unique": { + "name": "student_favourited_courses_student_id_course_id_unique", + "nullsNotDistinct": false, + "columns": [ + "student_id", + "course_id" + ] + } + } + }, + "public.student_lessons_progress": { + "name": "student_lessons_progress", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "student_id": { + "name": "student_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "lesson_id": { + "name": "lesson_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "completed_lesson_item_count": { + "name": "completed_lesson_item_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "quiz_completed": { + "name": "quiz_completed", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "quiz_score": { + "name": "quiz_score", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_as_freemium": { + "name": "completed_as_freemium", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "student_lessons_progress_student_id_users_id_fk": { + "name": "student_lessons_progress_student_id_users_id_fk", + "tableFrom": "student_lessons_progress", + "tableTo": "users", + "columnsFrom": [ + "student_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "student_lessons_progress_course_id_courses_id_fk": { + "name": "student_lessons_progress_course_id_courses_id_fk", + "tableFrom": "student_lessons_progress", + "tableTo": "courses", + "columnsFrom": [ + "course_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "student_lessons_progress_lesson_id_lessons_id_fk": { + "name": "student_lessons_progress_lesson_id_lessons_id_fk", + "tableFrom": "student_lessons_progress", + "tableTo": "lessons", + "columnsFrom": [ + "lesson_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "student_lessons_progress_student_id_lesson_id_course_id_unique": { + "name": "student_lessons_progress_student_id_lesson_id_course_id_unique", + "nullsNotDistinct": false, + "columns": [ + "student_id", + "lesson_id", + "course_id" + ] + } + } + }, + "public.student_question_answers": { + "name": "student_question_answers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "course_id": { + "name": "course_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "lesson_id": { + "name": "lesson_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "question_id": { + "name": "question_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "student_id": { + "name": "student_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "answer": { + "name": "answer", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "is_correct": { + "name": "is_correct", + "type": "boolean", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "student_question_answers_course_id_courses_id_fk": { + "name": "student_question_answers_course_id_courses_id_fk", + "tableFrom": "student_question_answers", + "tableTo": "courses", + "columnsFrom": [ + "course_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "student_question_answers_lesson_id_lessons_id_fk": { + "name": "student_question_answers_lesson_id_lessons_id_fk", + "tableFrom": "student_question_answers", + "tableTo": "lessons", + "columnsFrom": [ + "lesson_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "student_question_answers_question_id_questions_id_fk": { + "name": "student_question_answers_question_id_questions_id_fk", + "tableFrom": "student_question_answers", + "tableTo": "questions", + "columnsFrom": [ + "question_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "student_question_answers_student_id_users_id_fk": { + "name": "student_question_answers_student_id_users_id_fk", + "tableFrom": "student_question_answers", + "tableTo": "users", + "columnsFrom": [ + "student_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "student_question_answers_lesson_id_question_id_student_id_unique": { + "name": "student_question_answers_lesson_id_question_id_student_id_unique", + "nullsNotDistinct": false, + "columns": [ + "lesson_id", + "question_id", + "student_id" + ] + } + } + }, + "public.text_blocks": { + "name": "text_blocks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "title": { + "name": "title", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "text_blocks_author_id_users_id_fk": { + "name": "text_blocks_author_id_users_id_fk", + "tableFrom": "text_blocks", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.user_details": { + "name": "user_details", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "contact_phone_number": { + "name": "contact_phone_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contact_email": { + "name": "contact_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_title": { + "name": "job_title", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_details_user_id_users_id_fk": { + "name": "user_details_user_id_users_id_fk", + "tableFrom": "user_details", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_details_user_id_unique": { + "name": "user_details_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + } + }, + "public.user_statistics": { + "name": "user_statistics", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "current_streak": { + "name": "current_streak", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "longest_streak": { + "name": "longest_streak", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_activity_date": { + "name": "last_activity_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "activity_history": { + "name": "activity_history", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + } + }, + "indexes": {}, + "foreignKeys": { + "user_statistics_user_id_users_id_fk": { + "name": "user_statistics_user_id_users_id_fk", + "tableFrom": "user_statistics", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_statistics_user_id_unique": { + "name": "user_statistics_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + } + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'student'" + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + } + } + }, + "enums": {}, + "schemas": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/api/src/storage/migrations/meta/_journal.json b/apps/api/src/storage/migrations/meta/_journal.json index f150c0edf..a25074c53 100644 --- a/apps/api/src/storage/migrations/meta/_journal.json +++ b/apps/api/src/storage/migrations/meta/_journal.json @@ -85,6 +85,13 @@ "when": 1732502961161, "tag": "0011_update_courses_table", "breakpoints": true + }, + { + "idx": 12, + "version": "7", + "when": 1732759437699, + "tag": "0012_create_materialized_view_course_statistics_per_teacher", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/apps/api/src/storage/schema/index.ts b/apps/api/src/storage/schema/index.ts index c695cb00d..103d3187c 100644 --- a/apps/api/src/storage/schema/index.ts +++ b/apps/api/src/storage/schema/index.ts @@ -1,7 +1,9 @@ +import { sql } from "drizzle-orm"; import { boolean, integer, jsonb, + pgMaterializedView, pgTable, text, timestamp, @@ -380,8 +382,49 @@ export const studentLessonsProgress = pgTable( withTimezone: true, precision: 3, }), + completedAsFreemium: boolean("completed_as_freemium").notNull().default(false), }, (table) => ({ unq: unique().on(table.studentId, table.lessonId, table.courseId), }), ); + +// Materialized view + +export const courseStatisticsPerTeacherMaterialized = pgMaterializedView( + "course_statistics_per_teacher", + { + teacherId: uuid("teacher_id").notNull(), + courseId: uuid("course_id").notNull(), + studentCount: integer("student_count").notNull(), + completedStudentCount: integer("completed_student_count").notNull(), + purchasedAfterFreemiumCount: integer("purchased_after_freemium_count").notNull(), + }, +).as( + sql` + SELECT + c.author_id AS teacher_id, + c.id AS course_id, + COUNT(sc.id) AS student_count, + SUM( + CASE + WHEN sc.state = 'completed' THEN 1 + ELSE 0 + END + ) AS completed_student_count, + COUNT( + DISTINCT CASE + WHEN slp.completed_as_freemium = true THEN sc.student_id + ELSE NULL + END + ) AS purchased_after_freemium_count + FROM + courses c + LEFT JOIN student_courses sc ON c.id = sc.course_id + LEFT JOIN student_lessons_progress slp ON c.id = slp.course_id + AND slp.completed_at IS NOT NULL + GROUP BY + c.author_id, + c.id; +`, +); diff --git a/apps/api/src/swagger/api-schema.json b/apps/api/src/swagger/api-schema.json index 0cd6b3f14..048fe61d5 100644 --- a/apps/api/src/swagger/api-schema.json +++ b/apps/api/src/swagger/api-schema.json @@ -2590,7 +2590,7 @@ } } }, - "/api/statistics": { + "/api/statistics/user-stats": { "get": { "operationId": "StatisticsController_getUserStatistics", "parameters": [], @@ -2606,6 +2606,23 @@ } } } + }, + "/api/statistics/teacher-stats": { + "get": { + "operationId": "StatisticsController_getTeacherStats", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetTeacherStatsResponse" + } + } + } + } + } + } } }, "info": { @@ -7027,6 +7044,40 @@ "required": [ "data" ] + }, + "GetTeacherStatsResponse": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "mostFivePopularCourses": { + "type": "array", + "items": { + "type": "object", + "properties": { + "courseName": { + "type": "string" + }, + "studentCount": { + "type": "number" + } + }, + "required": [ + "courseName", + "studentCount" + ] + } + } + }, + "required": [ + "mostFivePopularCourses" + ] + } + }, + "required": [ + "data" + ] } } } diff --git a/apps/web/app/api/generated-api.ts b/apps/web/app/api/generated-api.ts index 6a21fc313..e174f455c 100644 --- a/apps/web/app/api/generated-api.ts +++ b/apps/web/app/api/generated-api.ts @@ -1198,6 +1198,15 @@ export interface GetUserStatisticsResponse { }; } +export interface GetTeacherStatsResponse { + data: { + mostFivePopularCourses: { + courseName: string; + studentCount: number; + }[]; + }; +} + import type { AxiosInstance, AxiosRequestConfig, @@ -1340,7 +1349,7 @@ export class HttpClient { ...requestParams, headers: { ...(requestParams.headers || {}), - ...(type && type !== ContentType.FormData ? { "Content-Type": type } : {}), + ...(type ? { "Content-Type": type } : {}), }, params: query, responseType: responseFormat, @@ -2747,11 +2756,25 @@ export class API extends HttpClient this.request({ - path: `/api/statistics`, + path: `/api/statistics/user-stats`, + method: "GET", + format: "json", + ...params, + }), + + /** + * No description + * + * @name StatisticsControllerGetTeacherStats + * @request GET:/api/statistics/teacher-stats + */ + statisticsControllerGetTeacherStats: (params: RequestParams = {}) => + this.request({ + path: `/api/statistics/teacher-stats`, method: "GET", format: "json", ...params, diff --git a/apps/web/app/api/queries/useUserStatistics.ts b/apps/web/app/api/queries/useUserStatistics.ts index 8291cbce2..4529080a6 100644 --- a/apps/web/app/api/queries/useUserStatistics.ts +++ b/apps/web/app/api/queries/useUserStatistics.ts @@ -6,7 +6,7 @@ import type { GetUserStatisticsResponse } from "../generated-api"; export const userStatistics = () => { return { - queryKey: ["user-statistics"], + queryKey: ["user-statistics/user-stats"], queryFn: async () => { const response = await ApiClient.api.statisticsControllerGetUserStatistics();