Skip to content

Commit

Permalink
feat: add SCORM course support with metadata retrieval and content se…
Browse files Browse the repository at this point in the history
…rving
  • Loading branch information
typeWolffo committed Dec 16, 2024
1 parent 57cdc60 commit f9d18ac
Show file tree
Hide file tree
Showing 18 changed files with 2,206 additions and 41 deletions.
3 changes: 3 additions & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
28 changes: 15 additions & 13 deletions apps/api/src/courses/course.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,7 @@ export class CourseService {
completedChapterCount: sql<number>`COALESCE(${studentCourses.finishedChapterCount}, 0)`,
enrolled: sql<boolean>`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,
Expand Down Expand Up @@ -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
Expand All @@ -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}
Expand Down Expand Up @@ -638,6 +639,7 @@ export class CourseService {
isPublished: createCourseBody.isPublished,
priceInCents: createCourseBody.priceInCents,
currency: createCourseBody.currency || "usd",
isScorm: createCourseBody.isScorm,
authorId,
categoryId: createCourseBody.categoryId,
})
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/courses/schemas/createCourse.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/courses/schemas/showCourseCommon.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
10 changes: 10 additions & 0 deletions apps/api/src/s3/s3.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,16 @@ export class S3Service {
return getSignedUrl(this.s3Client, command, { expiresIn });
}

async getFileContent(key: string): Promise<string> {
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<void> {
const command = new PutObjectCommand({
Bucket: this.bucketName,
Expand Down
50 changes: 37 additions & 13 deletions apps/api/src/scorm/scorm.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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,
});
}
}
3 changes: 2 additions & 1 deletion apps/api/src/scorm/scorm.module.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
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";
import { ScormController } from "./scorm.controller";
import { ScormService } from "./services/scorm.service";

@Module({
imports: [S3Module],
imports: [S3Module, FileModule],
controllers: [ScormController],
providers: [ScormService, ScormRepository],
exports: [ScormService],
Expand Down
Loading

0 comments on commit f9d18ac

Please sign in to comment.