diff --git a/apps/api/package.json b/apps/api/package.json index 37c4b7f66..2219db61e 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -54,6 +54,7 @@ "@types/multer": "1.4.12", "add": "2.0.6", "adm-zip": "0.5.16", + "axios": "1.7.2", "bcrypt": "5.1.1", "cache-manager": "6.2.0", "cookie": "0.6.0", @@ -64,6 +65,7 @@ "drizzle-orm": "0.31.2", "drizzle-typebox": "0.1.1", "faker": "link:@types/@faker-js/faker", + "jsdom": "24.1.1", "lodash": "4.17.21", "nanoid": "3.3.7", "nestjs-redis": "1.3.3", @@ -92,6 +94,7 @@ "@types/cookie-parser": "1.4.7", "@types/express": "4.17.17", "@types/jest": "29.5.2", + "@types/jsdom": "21.1.7", "@types/lodash": "4.17.6", "@types/node": "20.17.6", "@types/nodemailer": "6.4.15", diff --git a/apps/api/src/courses/course.service.ts b/apps/api/src/courses/course.service.ts index 75e62aaf5..7a12a4113 100644 --- a/apps/api/src/courses/course.service.ts +++ b/apps/api/src/courses/course.service.ts @@ -291,6 +291,7 @@ export class CourseService { completedChapterCount: sql`COALESCE(${studentCourses.finishedChapterCount}, 0)`, enrolled: sql`CASE WHEN ${studentCourses.studentId} IS NOT NULL THEN TRUE ELSE FALSE END`, isPublished: courses.isPublished, + isScorm: courses.isScorm, priceInCents: courses.priceInCents, currency: courses.currency, authorId: courses.authorId, @@ -357,7 +358,7 @@ export class CourseService { WHEN ( SELECT COUNT(*) FROM ${studentLessonProgress} - LEFT JOIN ${lessons} ON ${lessons.id} = ${studentLessonProgress.lessonId} + LEFT JOIN ${lessons} ON ${lessons.id} = ${studentLessonProgress.lessonId} WHERE ${studentLessonProgress.lessonId} = ${lessons.id} AND ${studentLessonProgress.studentId} = ${userId} ) > 0 @@ -376,25 +377,25 @@ export class CourseService { ${lessons.title} AS title, ${lessons.type} AS type, ${lessons.displayOrder} AS "displayOrder", - CASE - WHEN ${studentLessonProgress.completedAt} IS NOT NULL THEN 'completed' - WHEN ${studentLessonProgress.completedAt} IS NULL - AND ${studentLessonProgress.completedQuestionCount} > 0 THEN 'in_progress' - ELSE 'not_started' + CASE + WHEN ${studentLessonProgress.completedAt} IS NOT NULL THEN 'completed' + WHEN ${studentLessonProgress.completedAt} IS NULL + AND ${studentLessonProgress.completedQuestionCount} > 0 THEN 'in_progress' + ELSE 'not_started' END AS status, - CASE - WHEN ${lessons.type} = ${LESSON_TYPES.quiz} THEN COUNT(${questions.id}) - ELSE NULL + CASE + WHEN ${lessons.type} = ${LESSON_TYPES.quiz} THEN COUNT(${questions.id}) + ELSE NULL END AS "quizQuestionCount" FROM ${lessons} LEFT JOIN ${studentLessonProgress} ON ${lessons.id} = ${studentLessonProgress.lessonId} AND ${studentLessonProgress.studentId} = ${userId} LEFT JOIN ${questions} ON ${lessons.id} = ${questions.lessonId} WHERE ${lessons.chapterId} = ${chapters.id} - GROUP BY - ${lessons.id}, - ${lessons.type}, - ${lessons.displayOrder}, + GROUP BY + ${lessons.id}, + ${lessons.type}, + ${lessons.displayOrder}, ${lessons.title}, ${studentLessonProgress.completedAt}, ${studentLessonProgress.completedQuestionCount} @@ -638,6 +639,7 @@ export class CourseService { isPublished: createCourseBody.isPublished, priceInCents: createCourseBody.priceInCents, currency: createCourseBody.currency || "usd", + isScorm: createCourseBody.isScorm, authorId, categoryId: createCourseBody.categoryId, }) diff --git a/apps/api/src/courses/schemas/createCourse.schema.ts b/apps/api/src/courses/schemas/createCourse.schema.ts index 8394a3196..344f1c107 100644 --- a/apps/api/src/courses/schemas/createCourse.schema.ts +++ b/apps/api/src/courses/schemas/createCourse.schema.ts @@ -8,6 +8,7 @@ export const baseCourseSchema = Type.Object({ priceInCents: Type.Optional(Type.Integer()), currency: Type.Optional(Type.String()), categoryId: Type.String({ format: "uuid" }), + isScorm: Type.Optional(Type.Boolean()), }); export const createCourseSchema = Type.Intersect([ diff --git a/apps/api/src/courses/schemas/showCourseCommon.schema.ts b/apps/api/src/courses/schemas/showCourseCommon.schema.ts index d041a95c4..f4dbb5cbf 100644 --- a/apps/api/src/courses/schemas/showCourseCommon.schema.ts +++ b/apps/api/src/courses/schemas/showCourseCommon.schema.ts @@ -17,6 +17,7 @@ export const commonShowCourseSchema = Type.Object({ completedChapterCount: Type.Optional(Type.Number()), enrolled: Type.Optional(Type.Boolean()), isPublished: Type.Union([Type.Boolean(), Type.Null()]), + isScorm: Type.Optional(Type.Boolean()), chapters: Type.Array(chapterSchema), priceInCents: Type.Number(), currency: Type.String(), diff --git a/apps/api/src/s3/s3.service.ts b/apps/api/src/s3/s3.service.ts index 9ab50129d..eb08357d1 100644 --- a/apps/api/src/s3/s3.service.ts +++ b/apps/api/src/s3/s3.service.ts @@ -33,6 +33,16 @@ export class S3Service { return getSignedUrl(this.s3Client, command, { expiresIn }); } + async getFileContent(key: string): Promise { + const command = new GetObjectCommand({ + Bucket: this.bucketName, + Key: key, + }); + + const response = await this.s3Client.send(command); + return response.Body?.transformToString() || ""; + } + async uploadFile(fileBuffer: Buffer, key: string, contentType: string): Promise { const command = new PutObjectCommand({ Bucket: this.bucketName, diff --git a/apps/api/src/scorm/scorm.controller.ts b/apps/api/src/scorm/scorm.controller.ts index 4fbe5e504..43f5521dd 100644 --- a/apps/api/src/scorm/scorm.controller.ts +++ b/apps/api/src/scorm/scorm.controller.ts @@ -9,20 +9,20 @@ import { Param, Res, BadRequestException, + Header, + Req, } from "@nestjs/common"; import { FileInterceptor } from "@nestjs/platform-express"; import { ApiBody, ApiConsumes, ApiResponse } from "@nestjs/swagger"; -import { Type } from "@sinclair/typebox"; -import { Response } from "express"; -import { Validate } from "nestjs-typebox"; +import { Response, Request } from "express"; -import { BaseResponse, UUIDSchema, UUIDType } from "src/common"; +import { 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 { ScormUploadResponse } from "./schemas/scorm.schema"; +import { ScormMetadata, ScormUploadResponse } from "./schemas/scorm.schema"; import { ScormService } from "./services/scorm.service"; @Controller("scorm") @@ -77,22 +77,46 @@ export class ScormController { @Roles(...Object.values(USER_ROLES)) @Get(":courseId/content") - @Validate({ - request: [ - { type: "param", name: "courseId", schema: UUIDSchema }, - { type: "query", name: "path", schema: Type.String() }, - ], - }) + @Header("Cache-Control", "no-store") async serveScormContent( @Param("courseId") courseId: UUIDType, @Query("path") filePath: string, @Res() res: Response, + @Req() req: Request, ) { if (!filePath) { throw new BadRequestException("filePath is required"); } - const url = await this.scormService.serveContent(courseId, filePath); - return res.redirect(url); + const baseUrl = `${req.protocol}://${req.get("host")}`; + const content = await this.scormService.serveContent(courseId, filePath, baseUrl); + + if (filePath.endsWith(".html")) { + res.setHeader("Content-Type", "text/html"); + return res.send(content); + } + + const contentType = this.scormService.getContentType(filePath); + res.setHeader("Content-Type", contentType); + + if (typeof content === "string") { + return res.redirect(content); + } + + return res.send(content); + } + + @Get(":courseId/metadata") + @Roles(...Object.values(USER_ROLES)) + @ApiResponse({ + status: 200, + description: "Returns SCORM metadata including entry point path", + type: ScormMetadata, + }) + async getScormMetadata(@Param("courseId") courseId: UUIDType) { + const metadata = await this.scormService.getCourseScormMetadata(courseId); + return new BaseResponse({ + metadata, + }); } } diff --git a/apps/api/src/scorm/scorm.module.ts b/apps/api/src/scorm/scorm.module.ts index ab0868981..c8b93b7ba 100644 --- a/apps/api/src/scorm/scorm.module.ts +++ b/apps/api/src/scorm/scorm.module.ts @@ -1,5 +1,6 @@ import { Module } from "@nestjs/common"; +import { FileModule } from "src/file/files.module"; import { S3Module } from "src/s3/s3.module"; import { ScormRepository } from "./repositories/scorm.repository"; @@ -7,7 +8,7 @@ import { ScormController } from "./scorm.controller"; import { ScormService } from "./services/scorm.service"; @Module({ - imports: [S3Module], + imports: [S3Module, FileModule], controllers: [ScormController], providers: [ScormService, ScormRepository], exports: [ScormService], diff --git a/apps/api/src/scorm/services/scorm.service.ts b/apps/api/src/scorm/services/scorm.service.ts index 7c398eac9..4d1f761c6 100644 --- a/apps/api/src/scorm/services/scorm.service.ts +++ b/apps/api/src/scorm/services/scorm.service.ts @@ -1,10 +1,13 @@ import { randomUUID } from "crypto"; +import path from "path"; import { Injectable, Inject, BadRequestException, NotFoundException } from "@nestjs/common"; import AdmZip from "adm-zip"; +import { JSDOM } from "jsdom"; import xml2js from "xml2js"; import { DatabasePg } from "src/common"; +import { FileService } from "src/file/file.service"; import { S3Service } from "src/s3/s3.service"; import { SCORM } from "../constants/scorm.consts"; @@ -17,6 +20,7 @@ export class ScormService { constructor( @Inject("DB") private readonly db: DatabasePg, private readonly s3Service: S3Service, + private readonly fileService: FileService, private readonly scormRepository: ScormRepository, ) {} @@ -113,15 +117,157 @@ export class ScormService { * @param filePath - Original path of file inside SCORM package * @returns Signed S3 URL for the file */ - async serveContent(courseId: UUIDType, filePath: string): Promise { + async serveContent( + courseId: UUIDType, + filePath: string, + baseUrl: string, + ): Promise { const metadata = await this.scormRepository.getScormMetadata(courseId); - if (!metadata) { throw new NotFoundException("SCORM content not found"); } + if (filePath.endsWith(".html")) { + const s3Key = `${metadata.s3Key}/${filePath}`; + const htmlContent = await this.s3Service.getFileContent(s3Key); + + const dom = new JSDOM(htmlContent, { + url: `${baseUrl}/api/scorm/${courseId}/content`, + }); + const document = dom.window.document; + const styles = document.querySelectorAll("style"); + for (const style of styles) { + if (style.textContent) { + style.textContent = await this.processStyleContent( + style.textContent, + filePath, + metadata.s3Key, + ); + } + } + + const linkStyles = document.querySelectorAll('link[rel="stylesheet"]'); + for (const link of linkStyles) { + const href = link.getAttribute("href"); + if (href) { + const absolutePath = this.resolveRelativePath(filePath, href); + const signedUrl = await this.s3Service.getSignedUrl(`${metadata.s3Key}/${absolutePath}`); + link.setAttribute("href", signedUrl); + } + } + + const elements = document.querySelectorAll("[src]"); + for (const element of elements) { + const src = element.getAttribute("src"); + if (src) { + const absolutePath = this.resolveRelativePath(filePath, src); + const signedUrl = await this.s3Service.getSignedUrl(`${metadata.s3Key}/${absolutePath}`); + element.setAttribute("src", signedUrl); + } + } + + return dom.serialize(); + } + const s3Key = `${metadata.s3Key}/${filePath}`; - return await this.s3Service.getSignedUrl(s3Key); + return await this.s3Service.getFileContent(s3Key); + } + + /** + * Process CSS content to handle @import rules and url() functions + */ + private async processStyleContent( + css: string, + basePath: string, + s3KeyPrefix: string, + ): Promise { + return await this.replaceAsync( + css, + /@import\s+(?:url\(['"]?([^'"]+)['"]?\)|['"]([^'"]+)['"]);/g, + async (fullMatch, urlImport, stringImport) => { + const importPath = (urlImport || stringImport)?.trim(); + if (!importPath) return fullMatch; + + const absolutePath = this.resolveRelativePath(basePath, importPath); + try { + const importedContent = await this.s3Service.getFileContent( + `${s3KeyPrefix}/${absolutePath}`, + ); + + const processedCss = await this.processStyleContent( + importedContent.toString(), + absolutePath, + s3KeyPrefix, + ); + + return processedCss; + } catch (error) { + console.warn(`Failed to process CSS import: ${importPath}`, error); + return fullMatch; + } + }, + ); + } + + /** + * Get SCORM metadata for a specific course + * + * @param courseId - Course ID to get SCORM metadata for + * @throws NotFoundException if no SCORM content exists for the course + * @returns SCORM metadata including version, entry point, and file location + */ + async getCourseScormMetadata(courseId: UUIDType) { + try { + const metadata = await this.scormRepository.getScormMetadata(courseId); + + if (!metadata) { + throw new NotFoundException(`No SCORM content found for course ${courseId}`); + } + + return metadata; + } catch (error) { + if (error instanceof NotFoundException) { + throw error; + } + console.error("Error fetching SCORM metadata:", error); + throw new NotFoundException(`No SCORM content found for course ${courseId}`); + } + } + + private resolveRelativePath(basePath: string, relativePath: string): string { + basePath = basePath.trim(); + relativePath = relativePath.trim(); + + if (relativePath.startsWith("/")) { + return relativePath.slice(1); + } + + const baseDir = path.dirname(basePath); + if (relativePath.startsWith("../")) { + const parts = baseDir.split("/"); + const relParts = relativePath.split("/"); + + const upCount = relParts.reduce((count, part) => (part === ".." ? count + 1 : count), 0); + + const newBase = parts.slice(0, Math.max(0, parts.length - upCount)); + return [...newBase, ...relParts.slice(upCount)].join("/"); + } + + return path.join(path.dirname(basePath), relativePath); + } + + private async replaceAsync( + str: string, + regex: RegExp, + asyncFn: (...args: string[]) => Promise, + ) { + const promises: Promise[] = []; + str.replace(regex, (match, ...args) => { + promises.push(asyncFn(match, ...args)); + return match; + }); + const data = await Promise.all(promises); + return str.replace(regex, () => data.shift() || ""); } /** @@ -229,7 +375,7 @@ export class ScormService { * - .css - Styling * - .jpg/.png - Images */ - private getContentType(filename: string): string { + public getContentType(filename: string): string { const map: Record = { ".html": "text/html", ".js": "application/javascript", diff --git a/apps/api/src/storage/migrations/0002_add_is_scorm_course_field.sql b/apps/api/src/storage/migrations/0002_add_is_scorm_course_field.sql new file mode 100644 index 000000000..b066d049f --- /dev/null +++ b/apps/api/src/storage/migrations/0002_add_is_scorm_course_field.sql @@ -0,0 +1 @@ +ALTER TABLE "courses" ADD COLUMN "is_scorm" boolean DEFAULT false NOT NULL; \ No newline at end of file diff --git a/apps/api/src/storage/migrations/meta/0002_snapshot.json b/apps/api/src/storage/migrations/meta/0002_snapshot.json new file mode 100644 index 000000000..a1f0f9c91 --- /dev/null +++ b/apps/api/src/storage/migrations/meta/0002_snapshot.json @@ -0,0 +1,1836 @@ +{ + "id": "33402d22-f063-4730-959c-59fc6f317be0", + "prevId": "e4451969-8016-44fc-83a8-2cb5310a1d1e", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.categories": { + "name": "categories", + "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": "text", + "primaryKey": false, + "notNull": true + }, + "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.chapters": { + "name": "chapters", + "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 + }, + "course_id": { + "name": "course_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "is_published": { + "name": "is_published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_freemium": { + "name": "is_freemium", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "lesson_count": { + "name": "lesson_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "chapters_course_id_courses_id_fk": { + "name": "chapters_course_id_courses_id_fk", + "tableFrom": "chapters", + "tableTo": "courses", + "columnsFrom": [ + "course_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chapters_author_id_users_id_fk": { + "name": "chapters_author_id_users_id_fk", + "tableFrom": "chapters", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.course_students_stats": { + "name": "course_students_stats", + "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 + }, + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "month": { + "name": "month", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "year": { + "name": "year", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "new_students_count": { + "name": "new_students_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "course_students_stats_course_id_courses_id_fk": { + "name": "course_students_stats_course_id_courses_id_fk", + "tableFrom": "course_students_stats", + "tableTo": "courses", + "columnsFrom": [ + "course_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "course_students_stats_author_id_users_id_fk": { + "name": "course_students_stats_author_id_users_id_fk", + "tableFrom": "course_students_stats", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "course_students_stats_course_id_month_year_unique": { + "name": "course_students_stats_course_id_month_year_unique", + "nullsNotDistinct": false, + "columns": [ + "course_id", + "month", + "year" + ] + } + } + }, + "public.courses": { + "name": "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" + }, + "title": { + "name": "title", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar(1000)", + "primaryKey": false, + "notNull": false + }, + "thumbnail_s3_key": { + "name": "thumbnail_s3_key", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "is_published": { + "name": "is_published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "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'" + }, + "chapter_count": { + "name": "chapter_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_scorm": { + "name": "is_scorm", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "category_id": { + "name": "category_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "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.courses_summary_stats": { + "name": "courses_summary_stats", + "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 + }, + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "free_purchased_count": { + "name": "free_purchased_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "paid_purchased_count": { + "name": "paid_purchased_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "paid_purchased_after_freemium_count": { + "name": "paid_purchased_after_freemium_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "completed_freemium_student_count": { + "name": "completed_freemium_student_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "completed_course_student_count": { + "name": "completed_course_student_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "courses_summary_stats_course_id_courses_id_fk": { + "name": "courses_summary_stats_course_id_courses_id_fk", + "tableFrom": "courses_summary_stats", + "tableTo": "courses", + "columnsFrom": [ + "course_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "courses_summary_stats_author_id_users_id_fk": { + "name": "courses_summary_stats_author_id_users_id_fk", + "tableFrom": "courses_summary_stats", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "courses_summary_stats_course_id_unique": { + "name": "courses_summary_stats_course_id_unique", + "nullsNotDistinct": false, + "columns": [ + "course_id" + ] + } + } + }, + "public.create_tokens": { + "name": "create_tokens", + "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 + }, + "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 + } + }, + "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.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" + }, + "chapter_id": { + "name": "chapter_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar(1000)", + "primaryKey": false, + "notNull": false + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "file_s3_key": { + "name": "file_s3_key", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "file_type": { + "name": "file_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "lessons_chapter_id_chapters_id_fk": { + "name": "lessons_chapter_id_chapters_id_fk", + "tableFrom": "lessons", + "tableTo": "chapters", + "columnsFrom": [ + "chapter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "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": "varchar(100)", + "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": "cascade", + "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" + }, + "lesson_id": { + "name": "lesson_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "solution_explanation": { + "name": "solution_explanation", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "questions_lesson_id_lessons_id_fk": { + "name": "questions_lesson_id_lessons_id_fk", + "tableFrom": "questions", + "tableTo": "lessons", + "columnsFrom": [ + "lesson_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "questions_author_id_users_id_fk": { + "name": "questions_author_id_users_id_fk", + "tableFrom": "questions", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "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()" + }, + "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 + }, + "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 + } + }, + "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.scorm_files": { + "name": "scorm_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": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "s3_key_path": { + "name": "s3_key_path", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.scorm_metadata": { + "name": "scorm_metadata", + "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 + }, + "file_id": { + "name": "file_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entry_point": { + "name": "entry_point", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "s3_key": { + "name": "s3_key", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "scorm_metadata_course_id_courses_id_fk": { + "name": "scorm_metadata_course_id_courses_id_fk", + "tableFrom": "scorm_metadata", + "tableTo": "courses", + "columnsFrom": [ + "course_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "scorm_metadata_file_id_scorm_files_id_fk": { + "name": "scorm_metadata_file_id_scorm_files_id_fk", + "tableFrom": "scorm_metadata", + "tableTo": "scorm_files", + "columnsFrom": [ + "file_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.student_chapter_progress": { + "name": "student_chapter_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 + }, + "chapter_id": { + "name": "chapter_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "completed_lesson_count": { + "name": "completed_lesson_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "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_chapter_progress_student_id_users_id_fk": { + "name": "student_chapter_progress_student_id_users_id_fk", + "tableFrom": "student_chapter_progress", + "tableTo": "users", + "columnsFrom": [ + "student_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "student_chapter_progress_course_id_courses_id_fk": { + "name": "student_chapter_progress_course_id_courses_id_fk", + "tableFrom": "student_chapter_progress", + "tableTo": "courses", + "columnsFrom": [ + "course_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "student_chapter_progress_chapter_id_chapters_id_fk": { + "name": "student_chapter_progress_chapter_id_chapters_id_fk", + "tableFrom": "student_chapter_progress", + "tableTo": "chapters", + "columnsFrom": [ + "chapter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "student_chapter_progress_student_id_course_id_chapter_id_unique": { + "name": "student_chapter_progress_student_id_course_id_chapter_id_unique", + "nullsNotDistinct": false, + "columns": [ + "student_id", + "course_id", + "chapter_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 + }, + "progress": { + "name": "progress", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'not_started'" + }, + "finished_chapter_count": { + "name": "finished_chapter_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "payment_id": { + "name": "payment_id", + "type": "varchar(50)", + "primaryKey": false, + "notNull": 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_lesson_progress": { + "name": "student_lesson_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 + }, + "lesson_id": { + "name": "lesson_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "completed_question_count": { + "name": "completed_question_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "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 + } + }, + "indexes": {}, + "foreignKeys": { + "student_lesson_progress_student_id_users_id_fk": { + "name": "student_lesson_progress_student_id_users_id_fk", + "tableFrom": "student_lesson_progress", + "tableTo": "users", + "columnsFrom": [ + "student_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "student_lesson_progress_lesson_id_lessons_id_fk": { + "name": "student_lesson_progress_lesson_id_lessons_id_fk", + "tableFrom": "student_lesson_progress", + "tableTo": "lessons", + "columnsFrom": [ + "lesson_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "student_lesson_progress_student_id_lesson_id_unique": { + "name": "student_lesson_progress_student_id_lesson_id_unique", + "nullsNotDistinct": false, + "columns": [ + "student_id", + "lesson_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" + }, + "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_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": "cascade", + "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": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "student_question_answers_question_id_student_id_unique": { + "name": "student_question_answers_question_id_student_id_unique", + "nullsNotDistinct": false, + "columns": [ + "question_id", + "student_id" + ] + } + } + }, + "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 1fae5edd4..02734691a 100644 --- a/apps/api/src/storage/migrations/meta/_journal.json +++ b/apps/api/src/storage/migrations/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1733915188531, "tag": "0001_create_scorm_metadata", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1734361149627, + "tag": "0002_add_is_scorm_course_field", + "breakpoints": true } ] } diff --git a/apps/api/src/storage/schema/index.ts b/apps/api/src/storage/schema/index.ts index 5360be9c9..d5a9a6974 100644 --- a/apps/api/src/storage/schema/index.ts +++ b/apps/api/src/storage/schema/index.ts @@ -123,6 +123,7 @@ export const courses = pgTable("courses", { priceInCents: integer("price_in_cents").notNull().default(0), currency: varchar("currency").notNull().default("usd"), chapterCount: integer("chapter_count").notNull().default(0), + isScorm: boolean("is_scorm").notNull().default(false), authorId: uuid("author_id") .references(() => users.id) .notNull(), diff --git a/apps/api/src/swagger/api-schema.json b/apps/api/src/swagger/api-schema.json index de98f6d8a..e1de73ba6 100644 --- a/apps/api/src/swagger/api-schema.json +++ b/apps/api/src/swagger/api-schema.json @@ -1949,13 +1949,12 @@ "required": true, "in": "path", "schema": { - "format": "uuid", "type": "string" } }, { "name": "path", - "required": false, + "required": true, "in": "query", "schema": { "type": "string" @@ -1968,6 +1967,33 @@ } } } + }, + "/api/scorm/{courseId}/metadata": { + "get": { + "operationId": "ScormController_getScormMetadata", + "parameters": [ + { + "name": "courseId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Returns SCORM metadata including entry point path", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScormMetadata" + } + } + } + } + } + } } }, "info": { @@ -3431,6 +3457,9 @@ } ] }, + "isScorm": { + "type": "boolean" + }, "chapters": { "type": "array", "items": { @@ -3576,6 +3605,9 @@ } ] }, + "isScorm": { + "type": "boolean" + }, "chapters": { "type": "array", "items": { @@ -3721,6 +3753,9 @@ } ] }, + "isScorm": { + "type": "boolean" + }, "chapters": { "type": "array", "items": { @@ -3838,6 +3873,9 @@ "categoryId": { "format": "uuid", "type": "string" + }, + "isScorm": { + "type": "boolean" } }, "required": [ diff --git a/apps/web/app/api/generated-api.ts b/apps/web/app/api/generated-api.ts index 8707e1aa0..4cfa9b80f 100644 --- a/apps/web/app/api/generated-api.ts +++ b/apps/web/app/api/generated-api.ts @@ -448,6 +448,7 @@ export interface GetCourseResponse { completedChapterCount?: number; enrolled?: boolean; isPublished: boolean | null; + isScorm?: boolean; chapters: { /** @format uuid */ id: string; @@ -487,6 +488,7 @@ export interface GetCourseByIdResponse { completedChapterCount?: number; enrolled?: boolean; isPublished: boolean | null; + isScorm?: boolean; chapters: { /** @format uuid */ id: string; @@ -526,6 +528,7 @@ export interface GetBetaCourseByIdResponse { completedChapterCount?: number; enrolled?: boolean; isPublished: boolean | null; + isScorm?: boolean; chapters: { /** @format uuid */ id: string; @@ -556,6 +559,7 @@ export type CreateCourseBody = { currency?: string; /** @format uuid */ categoryId: string; + isScorm?: boolean; } & { chapters?: string[]; }; @@ -1978,8 +1982,8 @@ export class API extends HttpClient @@ -1989,5 +1993,19 @@ export class API extends HttpClient + this.request({ + path: `/api/scorm/${courseId}/metadata`, + method: "GET", + format: "json", + ...params, + }), }; } diff --git a/apps/web/app/modules/Admin/Scorm/components/StatusStep.tsx b/apps/web/app/modules/Admin/Scorm/components/StatusStep.tsx index c1f712999..53975c4a1 100644 --- a/apps/web/app/modules/Admin/Scorm/components/StatusStep.tsx +++ b/apps/web/app/modules/Admin/Scorm/components/StatusStep.tsx @@ -41,6 +41,8 @@ export function StatusStep({ handleBack, handleNext: _ }: StepComponentProps) { description: data.details.description, title: data.details.title, thumbnailS3Key: thumbnailResult?.fileKey, + isScorm: true, + priceInCents: data.pricing.price || 0 * 100, }, }); diff --git a/apps/web/app/modules/Admin/Scorm/store/scormForm.store.ts b/apps/web/app/modules/Admin/Scorm/store/scormForm.store.ts index b1fedd443..9a73ceac0 100644 --- a/apps/web/app/modules/Admin/Scorm/store/scormForm.store.ts +++ b/apps/web/app/modules/Admin/Scorm/store/scormForm.store.ts @@ -13,7 +13,7 @@ const initialState: CourseFormState = { description: "", thumbnail: null, }, - pricing: { type: "free", price: undefined, currency: "PLN" }, + pricing: { type: "free", price: 0, currency: "PLN" }, status: "draft", }, }; diff --git a/apps/web/app/modules/Courses/CourseView/CourseView.page.tsx b/apps/web/app/modules/Courses/CourseView/CourseView.page.tsx index d6397e40d..a2882254b 100644 --- a/apps/web/app/modules/Courses/CourseView/CourseView.page.tsx +++ b/apps/web/app/modules/Courses/CourseView/CourseView.page.tsx @@ -15,6 +15,7 @@ import { BreadcrumbSeparator, } from "~/components/ui/breadcrumb"; import { useUserRole } from "~/hooks/useUserRole"; +import { cn } from "~/lib/utils"; import CustomErrorBoundary from "~/modules/common/ErrorBoundary/ErrorBoundary"; import { CourseViewMainCard } from "./CourseViewMainCard"; @@ -48,7 +49,11 @@ export default function CoursesViewPage() { } return ( - + Dashboard @@ -59,8 +64,20 @@ export default function CoursesViewPage() {
- - + {course.isScorm ? ( + + ) : ( + <> + + + + )}
); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4986352f4..03726fea7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -101,6 +101,9 @@ importers: adm-zip: specifier: 0.5.16 version: 0.5.16 + axios: + specifier: 1.7.2 + version: 1.7.2 bcrypt: specifier: 5.1.1 version: 5.1.1 @@ -131,6 +134,9 @@ importers: faker: specifier: link:@types/@faker-js/faker version: link:@types/@faker-js/faker + jsdom: + specifier: 24.1.1 + version: 24.1.1(canvas@2.11.2) lodash: specifier: 4.17.21 version: 4.17.21 @@ -210,6 +216,9 @@ importers: '@types/jest': specifier: 29.5.2 version: 29.5.2 + '@types/jsdom': + specifier: 21.1.7 + version: 21.1.7 '@types/lodash': specifier: 4.17.6 version: 4.17.6 @@ -4624,6 +4633,9 @@ packages: '@types/js-cookie@2.2.7': resolution: {integrity: sha512-aLkWa0C0vO5b4Sr798E26QgOkss68Un0bLjs7u9qxzPT5CG+8DuNTffWES58YzJs3hrVAOs1wonycqEBqNJubA==} + '@types/jsdom@21.1.7': + resolution: {integrity: sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -8007,6 +8019,15 @@ packages: jsbn@1.1.0: resolution: {integrity: sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==} + jsdom@24.1.1: + resolution: {integrity: sha512-5O1wWV99Jhq4DV7rCLIoZ/UIhyQeDR7wHVyZAHAshbrvZsLs+Xzz7gtwnlJTJDjleiTKh54F4dXrX70vJQTyJQ==} + engines: {node: '>=18'} + peerDependencies: + canvas: ^2.11.2 + peerDependenciesMeta: + canvas: + optional: true + jsdom@24.1.3: resolution: {integrity: sha512-MyL55p3Ut3cXbeBEG7Hcv0mVM8pp8PBNWxRqchZnSfAiES1v1mRnMeFfaHWIPULpwsYfvO+ZmMZz5tGCnjzDUQ==} engines: {node: '>=18'} @@ -16320,6 +16341,12 @@ snapshots: '@types/js-cookie@2.2.7': {} + '@types/jsdom@21.1.7': + dependencies: + '@types/node': 20.17.6 + '@types/tough-cookie': 4.0.5 + parse5: 7.2.1 + '@types/json-schema@7.0.15': {} '@types/json5@0.0.29': {} @@ -19368,7 +19395,7 @@ snapshots: extract-zip@2.0.1: dependencies: - debug: 4.3.4 + debug: 4.3.7 get-stream: 5.2.0 yauzl: 2.10.0 optionalDependencies: @@ -20687,6 +20714,36 @@ snapshots: jsbn@1.1.0: {} + jsdom@24.1.1(canvas@2.11.2): + dependencies: + cssstyle: 4.1.0 + data-urls: 5.0.0 + decimal.js: 10.4.3 + form-data: 4.0.1 + html-encoding-sniffer: 4.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.5 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.13 + parse5: 7.2.1 + rrweb-cssom: 0.7.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 4.1.4 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 3.1.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 14.0.0 + ws: 8.18.0 + xml-name-validator: 5.0.0 + optionalDependencies: + canvas: 2.11.2 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + jsdom@24.1.3(canvas@2.11.2): dependencies: cssstyle: 4.1.0 @@ -22438,7 +22495,7 @@ snapshots: proxy-agent@6.3.1: dependencies: agent-base: 7.1.1 - debug: 4.3.4 + debug: 4.3.7 http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.5 lru-cache: 7.18.3