From 216856b9e235036bad235f8fbc93b7b9f39459ee Mon Sep 17 00:00:00 2001 From: Rajat Date: Tue, 13 Jan 2026 22:31:41 +0530 Subject: [PATCH 1/7] scorm lessons --- .../(sidebar)/product/[id]/content/page.tsx | 2 +- .../lesson/lesson-content-renderer.tsx | 39 ++ .../content/section/[section]/lesson/page.tsx | 20 + .../[section]/lesson/scorm-lesson-upload.tsx | 198 +++++++++ .../dashboard/(sidebar)/products/page.tsx | 41 +- .../api/lessons/[id]/scorm/upload/route.ts | 164 ++++++++ .../[lessonId]/content/[...path]/route.ts | 216 ++++++++++ .../scorm/lesson/[lessonId]/runtime/route.ts | 194 +++++++++ .../components/public/lesson-viewer/index.tsx | 19 +- .../public/lesson-viewer/scorm-viewer.tsx | 397 ++++++++++++++++++ apps/web/config/constants.ts | 6 + apps/web/graphql/courses/logic.ts | 25 -- apps/web/graphql/lessons/helpers.ts | 4 +- apps/web/graphql/lessons/logic.ts | 60 ++- apps/web/graphql/lessons/query.ts | 10 +- apps/web/graphql/lessons/types.ts | 3 +- apps/web/lib/scorm/extractor.ts | 318 ++++++++++++++ apps/web/models/Lesson.ts | 16 +- apps/web/models/Progress.ts | 3 + apps/web/package.json | 6 +- apps/web/ui-config/constants.ts | 1 + .../common-logic/src/models/user/progress.ts | 3 + packages/common-models/src/constants.ts | 1 + packages/common-models/src/index.ts | 1 + packages/common-models/src/lesson.ts | 3 +- packages/common-models/src/progress.ts | 5 + packages/common-models/src/scorm-content.ts | 8 + .../components-library/src/lesson-icon.tsx | 3 + packages/icons/src/index.tsx | 1 + packages/orm-models/src/models/lesson.ts | 3 +- .../orm-models/src/models/user/progress.ts | 3 + pnpm-lock.yaml | 53 +++ 32 files changed, 1758 insertions(+), 68 deletions(-) create mode 100644 apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/section/[section]/lesson/scorm-lesson-upload.tsx create mode 100644 apps/web/app/api/lessons/[id]/scorm/upload/route.ts create mode 100644 apps/web/app/api/scorm/lesson/[lessonId]/content/[...path]/route.ts create mode 100644 apps/web/app/api/scorm/lesson/[lessonId]/runtime/route.ts create mode 100644 apps/web/components/public/lesson-viewer/scorm-viewer.tsx create mode 100644 apps/web/lib/scorm/extractor.ts create mode 100644 packages/common-models/src/scorm-content.ts diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/page.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/page.tsx index 69da003d6..c31a32e3a 100644 --- a/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/page.tsx +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/page.tsx @@ -27,6 +27,7 @@ import { Video, HelpCircle, ChevronDown, + Droplets, } from "lucide-react"; import Link from "next/link"; import { @@ -52,7 +53,6 @@ import { TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; -import { Droplets } from "lucide-react"; const { permissions } = UIConstants; export default function ContentPage() { diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/section/[section]/lesson/lesson-content-renderer.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/section/[section]/lesson/lesson-content-renderer.tsx index 1b09eedf9..e5ebbbf8a 100644 --- a/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/section/[section]/lesson/lesson-content-renderer.tsx +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/section/[section]/lesson/lesson-content-renderer.tsx @@ -211,6 +211,45 @@ export function LessonContentRenderer({ )} ); + case Constants.LessonType.SCORM: + return ( +
+
+ {(lesson.content as any)?.mediaId ? ( +
+
+ + SCORM Package: + + + {(lesson.content as any)?.title || + "Uploaded"} + +
+
+ Version:{" "} + {(lesson.content as any)?.version || "1.2"} + {(lesson.content as any)?.fileCount && + ` • ${(lesson.content as any)?.fileCount} files`} +
+
+ ) : ( +
+

+ Save the lesson first, then upload the SCORM + package. +

+
+ )} +
+ {!lesson?.lessonId && ( +

+ + Save the lesson to enable SCORM package upload +

+ )} +
+ ); default: return null; } diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/section/[section]/lesson/page.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/section/[section]/lesson/page.tsx index 60ed59b8f..f824d85a1 100644 --- a/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/section/[section]/lesson/page.tsx +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/section/[section]/lesson/page.tsx @@ -12,6 +12,7 @@ import { HelpCircle, File, Tv, + Package, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -55,6 +56,7 @@ import { isTextEditorNonEmpty, truncate } from "@ui-lib/utils"; import { Separator } from "@components/ui/separator"; import { emptyDoc as TextEditorEmptyDoc } from "@courselit/text-editor"; import { LessonSkeleton } from "./skeleton"; +import { ScormLessonUpload } from "./scorm-lesson-upload"; const { permissions } = UIConstants; @@ -66,6 +68,7 @@ const lessonTypes = [ { value: Constants.LessonType.FILE, label: "File", icon: File }, { value: Constants.LessonType.EMBED, label: "Embed", icon: Tv }, { value: Constants.LessonType.QUIZ, label: "Quiz", icon: HelpCircle }, + { value: Constants.LessonType.SCORM, label: "SCORM", icon: Package }, ] as const; type LessonError = Partial>; @@ -640,6 +643,23 @@ export default function LessonPage() { )} + {lesson.type === Constants.LessonType.SCORM && + lesson.lessonId && ( + <> + + { + setLesson({ + ...lesson, + content: newContent, + }); + setContent(newContent); + }} + /> + + )} )} diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/section/[section]/lesson/scorm-lesson-upload.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/section/[section]/lesson/scorm-lesson-upload.tsx new file mode 100644 index 000000000..ce81df306 --- /dev/null +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/section/[section]/lesson/scorm-lesson-upload.tsx @@ -0,0 +1,198 @@ +"use client"; + +import { useState, useContext } from "react"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { + Upload, + Package, + CheckCircle, + Loader2, + FileWarning, +} from "lucide-react"; +import { useToast, useMediaLit } from "@courselit/components-library"; +import { AddressContext } from "@components/contexts"; +import { Progress as ShadProgress } from "@/components/ui/progress"; +import { ScormContent } from "@courselit/common-models"; +import constants from "@config/constants"; + +interface ScormLessonUploadProps { + lessonId: string; + content?: ScormContent; + onUploadComplete: (content: ScormContent) => void; +} + +export function ScormLessonUpload({ + lessonId, + content, + onUploadComplete, +}: ScormLessonUploadProps) { + const address = useContext(AddressContext); + const { toast } = useToast(); + const [uploading, setUploading] = useState(false); + const [processing, setProcessing] = useState(false); + const [error, setError] = useState(null); + + const { uploadFile, isUploading, uploadProgress } = useMediaLit({ + signatureEndpoint: `${address.backend}/api/media/presigned`, + access: "private", + onUploadComplete: async (response) => { + setUploading(false); + setProcessing(true); + + try { + const result = await fetch( + `${address.backend}/api/lessons/${lessonId}/scorm/upload`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ mediaId: response.mediaId }), + }, + ); + + const data = await result.json(); + + if (!result.ok) { + throw new Error( + data.message || "Failed to process SCORM package", + ); + } + + toast({ + title: "Success", + description: "SCORM package uploaded successfully", + }); + + onUploadComplete({ + mediaId: response.mediaId, + launchUrl: data.packageInfo.entryPoint, + version: data.packageInfo.version, + title: data.packageInfo.title, + scoCount: data.packageInfo.scoCount, + fileCount: data.packageInfo.fileCount, + }); + + setError(null); + } catch (err: any) { + setError(err.message); + toast({ + title: "Error", + description: err.message, + variant: "destructive", + }); + } finally { + setProcessing(false); + } + }, + onUploadError: (err) => { + setUploading(false); + setError(err.message || "Upload failed"); + toast({ + title: "Upload Failed", + description: err.message, + variant: "destructive", + }); + }, + }); + + const handleFileSelect = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + if (!file.name.endsWith(".zip")) { + setError("Please select a ZIP file"); + return; + } + + if (file.size > constants.scormPackageSizeLimit) { + setError( + `File size must be less than ${constants.scormPackageSizeLimit / 1024 / 1024}MB`, + ); + return; + } + + setError(null); + setUploading(true); + await uploadFile(file); + }; + + const hasPackage = content?.mediaId; + const showProgress = uploading || isUploading; + + return ( +
+ + + {error && ( +
+ + {error} +
+ )} + + {hasPackage && ( +
+
+
+
+ +
+
+

+ {content?.title || "SCORM Package"} +

+

+ Version {content?.version || "1.2"} + {content?.fileCount && + ` • ${content.fileCount} files`} +

+
+
+ +
+
+ )} + + {showProgress || processing ? ( +
+
+ + {processing + ? "Processing SCORM package..." + : "Uploading..."} +
+ {showProgress && ( + + )} +
+ ) : ( +
+ + +

+ Upload a SCORM 1.2 or 2004 package (ZIP file, max 500MB) +

+
+ )} +
+ ); +} diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/products/page.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/products/page.tsx index 1c0e1ec4e..124879e8f 100644 --- a/apps/web/app/(with-contexts)/dashboard/(sidebar)/products/page.tsx +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/products/page.tsx @@ -217,26 +217,27 @@ export default function Page() { - {totalPages > 0 && ( -
- -
- )} +
+ +
+ {totalPages > 0 ? ( +
// spacer + ) : null} {loading ? ( ) : ( diff --git a/apps/web/app/api/lessons/[id]/scorm/upload/route.ts b/apps/web/app/api/lessons/[id]/scorm/upload/route.ts new file mode 100644 index 000000000..4afe0f7c3 --- /dev/null +++ b/apps/web/app/api/lessons/[id]/scorm/upload/route.ts @@ -0,0 +1,164 @@ +import { NextRequest } from "next/server"; +import { auth } from "@/auth"; +import { checkPermission } from "@courselit/utils"; +import { UIConstants as constants, Constants } from "@courselit/common-models"; +import DomainModel, { Domain } from "@models/Domain"; +import User from "@models/User"; +import Lesson from "@models/Lesson"; +import { extractScormPackage } from "@/lib/scorm/extractor"; +import { MediaLit } from "medialit"; +import { error as logError } from "@/services/logger"; + +/** + * POST: Process a SCORM package for a lesson + * Expects: { mediaId: string } - the MediaLit file ID of the uploaded ZIP + * + * Flow: + * 1. Fetch ZIP from MediaLit using mediaId + * 2. Extract and validate imsmanifest.xml + * 3. Update lesson content with mediaId and launchUrl + */ +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const { id: lessonId } = await params; + + // Get domain + const domain = await DomainModel.findOne({ + name: req.headers.get("domain"), + }); + if (!domain) { + return Response.json({ message: "Domain not found" }, { status: 404 }); + } + + // Auth check + const session = await auth.api.getSession({ headers: req.headers }); + if (!session) { + return Response.json({ message: "Unauthorized" }, { status: 401 }); + } + + const user = await User.findOne({ + email: session.user?.email, + domain: domain._id, + active: true, + }); + if (!user) { + return Response.json({ message: "User not found" }, { status: 404 }); + } + + // Permission check + if ( + !checkPermission(user.permissions, [ + constants.permissions.manageCourse, + constants.permissions.manageAnyCourse, + ]) + ) { + return Response.json({ message: "Forbidden" }, { status: 403 }); + } + + // Get lesson + const lesson = await Lesson.findOne({ + lessonId, + domain: domain._id, + }); + if (!lesson) { + return Response.json({ message: "Lesson not found" }, { status: 404 }); + } + + // Check if lesson type is SCORM + if (lesson.type !== Constants.LessonType.SCORM) { + return Response.json( + { message: "Lesson is not a SCORM lesson" }, + { status: 400 }, + ); + } + + try { + // Get mediaId from request body + const body = await req.json(); + const { mediaId } = body; + + if (!mediaId) { + return Response.json( + { message: "mediaId is required" }, + { status: 400 }, + ); + } + + // Initialize MediaLit client + const medialit = new MediaLit({ + apiKey: process.env.MEDIALIT_APIKEY, + endpoint: process.env.MEDIALIT_SERVER, + }); + + // Get the uploaded ZIP file URL from MediaLit + const media = await medialit.get(mediaId); + if (!media?.file) { + return Response.json( + { message: "SCORM package not found in MediaLit" }, + { status: 404 }, + ); + } + + // Fetch the ZIP file content + const zipResponse = await fetch(media.file as string); + if (!zipResponse.ok) { + return Response.json( + { message: "Failed to fetch SCORM package" }, + { status: 500 }, + ); + } + const arrayBuffer = await zipResponse.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + + // Extract and validate SCORM package + const result = await extractScormPackage(buffer); + if (!result.success || !result.packageInfo) { + return Response.json( + { message: result.error || "Invalid SCORM package" }, + { status: 400 }, + ); + } + + const { packageInfo } = result; + + // Update lesson content with SCORM metadata + await Lesson.updateOne( + { lessonId, domain: domain._id }, + { + $set: { + content: { + mediaId, + launchUrl: packageInfo.entryPoint, + version: packageInfo.version, + title: packageInfo.title, + scoCount: packageInfo.scos.length, + fileCount: packageInfo.files.length, + }, + }, + }, + ); + + return Response.json({ + success: true, + message: "SCORM package processed successfully", + packageInfo: { + version: packageInfo.version, + title: packageInfo.title, + entryPoint: packageInfo.entryPoint, + scoCount: packageInfo.scos.length, + fileCount: packageInfo.files.length, + }, + }); + } catch (err: any) { + logError("SCORM processing failed", { + err: err.message, + stack: err.stack, + }); + return Response.json( + { message: "Failed to process SCORM package" }, + { status: 500 }, + ); + } +} diff --git a/apps/web/app/api/scorm/lesson/[lessonId]/content/[...path]/route.ts b/apps/web/app/api/scorm/lesson/[lessonId]/content/[...path]/route.ts new file mode 100644 index 000000000..9d2b68516 --- /dev/null +++ b/apps/web/app/api/scorm/lesson/[lessonId]/content/[...path]/route.ts @@ -0,0 +1,216 @@ +import { NextRequest } from "next/server"; +import { auth } from "@/auth"; +import DomainModel, { Domain } from "@models/Domain"; +import User from "@models/User"; +import Lesson from "@models/Lesson"; +import { MediaLit } from "medialit"; +import { isEnrolled } from "@/ui-lib/utils"; +import path from "path"; +import AdmZip from "adm-zip"; + +// Simple in-memory cache for extracted ZIP contents +const zipCache = new Map(); +const CACHE_TTL = 5 * 60 * 1000; // 5 minutes + +// MIME types for content +const MIME_TYPES: Record = { + ".html": "text/html", + ".htm": "text/html", + ".js": "application/javascript", + ".css": "text/css", + ".json": "application/json", + ".xml": "application/xml", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".svg": "image/svg+xml", + ".mp4": "video/mp4", + ".webm": "video/webm", + ".mp3": "audio/mpeg", + ".wav": "audio/wav", + ".woff": "font/woff", + ".woff2": "font/woff2", + ".ttf": "font/ttf", + ".eot": "application/vnd.ms-fontobject", + ".swf": "application/x-shockwave-flash", +}; + +/** + * Fetch ZIP from MediaLit with caching + */ +async function getZipBuffer( + mediaId: string, + medialit: MediaLit, +): Promise { + const cached = zipCache.get(mediaId); + if (cached && Date.now() - cached.timestamp < CACHE_TTL) { + return cached.buffer; + } + + try { + const media = await medialit.get(mediaId); + if (!media?.file) { + return null; + } + + const response = await fetch(media.file as string); + if (!response.ok) { + return null; + } + + const arrayBuffer = await response.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + + zipCache.set(mediaId, { buffer, timestamp: Date.now() }); + + // Clean up old cache entries + for (const [key, value] of Array.from(zipCache.entries())) { + if (Date.now() - value.timestamp > CACHE_TTL) { + zipCache.delete(key); + } + } + + return buffer; + } catch (error) { + console.error("Failed to fetch ZIP from MediaLit:", error); + return null; + } +} + +/** + * Extract a specific file from ZIP buffer + */ +function extractFileFromZip( + zipBuffer: Buffer, + filePath: string, +): Buffer | null { + try { + const zip = new AdmZip(zipBuffer); + const entry = zip.getEntry(filePath); + if (!entry) { + // Try case-insensitive match + const entries = zip.getEntries(); + const match = entries.find( + (e) => e.entryName.toLowerCase() === filePath.toLowerCase(), + ); + if (match) { + return match.getData(); + } + return null; + } + return entry.getData(); + } catch (error) { + console.error("Failed to extract file from ZIP:", error); + return null; + } +} + +/** + * GET: Serve SCORM content files from lesson's ZIP package + * Uses lessonId to look up the lesson and get mediaId from content field + */ +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ lessonId: string; path: string[] }> }, +) { + const { lessonId, path: pathParts } = await params; + const filePath = pathParts.join("/"); + + // Get domain + const domain = await DomainModel.findOne({ + name: req.headers.get("domain"), + }); + if (!domain) { + return Response.json({ message: "Domain not found" }, { status: 404 }); + } + + // Auth check + const session = await auth.api.getSession({ headers: req.headers }); + if (!session) { + return Response.json({ message: "Unauthorized" }, { status: 401 }); + } + + const user = await User.findOne({ + email: session.user?.email, + domain: domain._id, + active: true, + }); + if (!user) { + return Response.json({ message: "User not found" }, { status: 404 }); + } + + // Get lesson + const lesson = await Lesson.findOne({ + lessonId, + domain: domain._id, + }); + if (!lesson) { + return Response.json({ message: "Lesson not found" }, { status: 404 }); + } + + // Check enrollment for the course this lesson belongs to + const enrolled = isEnrolled(lesson.courseId, user); + if (!enrolled) { + return Response.json( + { message: "Enrollment required" }, + { status: 403 }, + ); + } + + // Get mediaId from lesson content (SCORM lessons store package info in content) + const scormContent = lesson.content as { + mediaId?: string; + launchUrl?: string; + }; + if (!scormContent?.mediaId) { + return Response.json( + { message: "SCORM package not found for this lesson" }, + { status: 404 }, + ); + } + + const { mediaId } = scormContent; + const ext = path.extname(filePath).toLowerCase(); + const mimeType = MIME_TYPES[ext] || "application/octet-stream"; + + // Initialize MediaLit client + const medialit = new MediaLit({ + apiKey: process.env.MEDIALIT_APIKEY, + endpoint: process.env.MEDIALIT_SERVER, + }); + + try { + // Get ZIP buffer (cached) + const zipBuffer = await getZipBuffer(mediaId, medialit); + if (!zipBuffer) { + return Response.json( + { message: "SCORM package not found" }, + { status: 404 }, + ); + } + + // Extract the requested file + const fileContent = extractFileFromZip(zipBuffer, filePath); + if (!fileContent) { + return Response.json( + { message: "File not found in SCORM package" }, + { status: 404 }, + ); + } + + // Return the file with appropriate headers + return new Response(new Uint8Array(fileContent), { + headers: { + "Content-Type": mimeType, + "Cache-Control": "public, max-age=3600", + }, + }); + } catch (error) { + console.error("SCORM content fetch error:", error); + return Response.json( + { message: "Failed to load content" }, + { status: 500 }, + ); + } +} diff --git a/apps/web/app/api/scorm/lesson/[lessonId]/runtime/route.ts b/apps/web/app/api/scorm/lesson/[lessonId]/runtime/route.ts new file mode 100644 index 000000000..d37483d6a --- /dev/null +++ b/apps/web/app/api/scorm/lesson/[lessonId]/runtime/route.ts @@ -0,0 +1,194 @@ +import { NextRequest } from "next/server"; +import { auth } from "@/auth"; +import DomainModel, { Domain } from "@models/Domain"; +import User from "@models/User"; +import Lesson from "@models/Lesson"; +import { isEnrolled } from "@/ui-lib/utils"; +import { error } from "@/services/logger"; + +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ lessonId: string }> }, +) { + const { lessonId } = await params; + + const domain = await DomainModel.findOne({ + name: req.headers.get("domain"), + }); + if (!domain) { + return Response.json({ message: "Domain not found" }, { status: 404 }); + } + + const session = await auth.api.getSession({ headers: req.headers }); + if (!session) { + return Response.json({ message: "Unauthorized" }, { status: 401 }); + } + + const user = await User.findOne({ + email: session.user?.email, + domain: domain._id, + active: true, + }); + if (!user) { + return Response.json({ message: "User not found" }, { status: 404 }); + } + + const lesson = await Lesson.findOne({ + lessonId, + domain: domain._id, + }); + if (!lesson) { + return Response.json({ message: "Lesson not found" }, { status: 404 }); + } + + const { courseId } = lesson; + + const enrolled = isEnrolled(courseId, user); + if (!enrolled) { + return Response.json( + { message: "Enrollment required" }, + { status: 403 }, + ); + } + + const purchase = user.purchases.find((p: any) => p.courseId === courseId); + if (!purchase) { + return Response.json( + { message: "Progress not found" }, + { status: 404 }, + ); + } + + const lessonScormData = purchase.scormData?.lessons?.[lessonId] || {}; + + const result = lessonScormData.cmi + ? lessonScormData + : { cmi: lessonScormData }; + + return Response.json(result); +} + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ lessonId: string }> }, +) { + const { lessonId } = await params; + + const domain = await DomainModel.findOne({ + name: req.headers.get("domain"), + }); + if (!domain) { + return Response.json({ message: "Domain not found" }, { status: 404 }); + } + + const session = await auth.api.getSession({ headers: req.headers }); + if (!session) { + return Response.json({ message: "Unauthorized" }, { status: 401 }); + } + + const user = await User.findOne({ + email: session.user?.email, + domain: domain._id, + active: true, + }); + if (!user) { + return Response.json({ message: "User not found" }, { status: 404 }); + } + + const lesson = await Lesson.findOne({ + lessonId, + domain: domain._id, + }); + if (!lesson) { + return Response.json({ message: "Lesson not found" }, { status: 404 }); + } + + const { courseId } = lesson; + + const enrolled = isEnrolled(courseId, user); + if (!enrolled) { + return Response.json( + { message: "Enrollment required" }, + { status: 403 }, + ); + } + + const body = await req.json(); + const { element, value, updates } = body; + + // Support both single update and batch updates + const updateEntries: [string, unknown][] = updates + ? Object.entries(updates) + : element + ? [[element, value]] + : []; + + if (updateEntries.length === 0) { + return Response.json( + { message: "No updates provided" }, + { status: 400 }, + ); + } + + try { + const userDoc = await User.findById(user._id); + if (!userDoc) { + return Response.json( + { message: "User not found" }, + { status: 404 }, + ); + } + + const purchase = userDoc.purchases.find( + (p: any) => p.courseId === courseId, + ); + if (!purchase) { + return Response.json( + { message: "Purchase not found" }, + { status: 404 }, + ); + } + + if (!purchase.scormData) { + purchase.scormData = { lessons: {} }; + } + if (!purchase.scormData.lessons) { + purchase.scormData.lessons = {}; + } + if (!purchase.scormData.lessons[lessonId]) { + purchase.scormData.lessons[lessonId] = {}; + } + + // Apply all updates + for (const [elem, val] of updateEntries) { + setNestedValue(purchase.scormData.lessons[lessonId], elem, val); + } + + userDoc.markModified("purchases"); + await userDoc.save(); + + return Response.json({ success: true }); + } catch (err: any) { + error(err.message, { + stack: err.stack, + }); + return Response.json( + { message: "Failed to save runtime data" }, + { status: 500 }, + ); + } +} + +function setNestedValue(obj: any, path: string, value: unknown): void { + const parts = path.split("."); + let current = obj; + for (let i = 0; i < parts.length - 1; i++) { + const part = parts[i]; + if (current[part] === undefined) { + const nextPart = parts[i + 1]; + current[part] = /^\d+$/.test(nextPart) ? [] : {}; + } + current = current[part]; + } + current[parts[parts.length - 1]] = value; +} diff --git a/apps/web/components/public/lesson-viewer/index.tsx b/apps/web/components/public/lesson-viewer/index.tsx index 667d7b224..73c9a2769 100644 --- a/apps/web/components/public/lesson-viewer/index.tsx +++ b/apps/web/components/public/lesson-viewer/index.tsx @@ -8,6 +8,7 @@ import { LESSON_TYPE_TEXT, LESSON_TYPE_EMBED, LESSON_TYPE_QUIZ, + LESSON_TYPE_SCORM, } from "@/ui-config/constants"; import { COURSE_PROGRESS_FINISH, @@ -32,6 +33,7 @@ import { ArrowLeft, ArrowRight, ArrowDownward } from "@courselit/icons"; import { isEnrolled } from "../../../ui-lib/utils"; import LessonEmbedViewer from "./embed-viewer"; import QuizViewer from "./quiz-viewer"; +import ScormViewer from "./scorm-viewer"; import { getUserProfile } from "@/app/(with-contexts)/helpers"; import WidgetErrorBoundary from "../base-layout/template/widget-error-boundary"; import { Button, Header1, Text1 } from "@courselit/page-primitives"; @@ -90,7 +92,7 @@ export const LessonViewer = ({ const loadLesson = async (id: string) => { const query = ` query { - lesson: getLessonDetails(id: "${id}") { + lesson: getLessonDetails(id: "${id}", courseId: "${productId}") { lessonId, title, downloadable, @@ -352,6 +354,21 @@ export const LessonViewer = ({
)} + {String.prototype.toUpperCase.call( + LESSON_TYPE_SCORM, + ) === lesson.type && + lesson.content && ( + + )} )} diff --git a/apps/web/components/public/lesson-viewer/scorm-viewer.tsx b/apps/web/components/public/lesson-viewer/scorm-viewer.tsx new file mode 100644 index 000000000..cb3a27c09 --- /dev/null +++ b/apps/web/components/public/lesson-viewer/scorm-viewer.tsx @@ -0,0 +1,397 @@ +"use client"; + +import { useEffect, useRef, useCallback, useState } from "react"; +import { debounce } from "@courselit/utils"; + +interface ScormViewerProps { + lessonId: string; + launchUrl: string; +} + +interface CMIData { + [key: string]: unknown; +} + +export function ScormViewer({ lessonId, launchUrl }: ScormViewerProps) { + const iframeRef = useRef(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + const [isDataLoaded, setIsDataLoaded] = useState(false); + const scormState = useRef({}); + const pendingUpdates = useRef>(new Map()); + const sessionActive = useRef(false); + + // Centralized GetValue logic + const getValue = useCallback((element: string): string => { + if (!sessionActive.current) { + console.warn(`[SCORM] GetValue rejected - session not active`); + return ""; + } + const value = getNestedValue(scormState.current, element); + return value !== undefined ? String(value) : ""; + }, []); + + // Debounced batch commit function + const commitToServer = useCallback( + debounce(async () => { + if (pendingUpdates.current.size === 0) return; + + const updates = Object.fromEntries(pendingUpdates.current); + pendingUpdates.current.clear(); + + try { + await fetch(`/api/scorm/lesson/${lessonId}/runtime`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ updates }), + }); + } catch (err) { + console.error("Failed to save SCORM data:", err); + // Re-queue failed updates + for (const [k, v] of Object.entries(updates)) { + pendingUpdates.current.set(k, v); + } + } + }, 500), + [lessonId], + ); + + // Centralized SetValue logic + const setValue = useCallback( + (element: string, value: string): string => { + if (!sessionActive.current) { + console.warn(`[SCORM] SetValue rejected - session not active`); + return "false"; + } + + // Fix C: Prevent infinite suspend_data growth (4KB limit for SCORM 1.2) + let safeValue = value; + if ( + element === "cmi.suspend_data" || + element === "cmi.core.suspend_data" + ) { + const maxSize = 4096; + if (value.length > maxSize) { + console.warn( + `[SCORM] suspend_data exceeded ${maxSize} bytes, truncating`, + ); + safeValue = value.slice(0, maxSize); + } + } + + setNestedValue(scormState.current, element, safeValue); + pendingUpdates.current.set(element, safeValue); + commitToServer(); + return "true"; + }, + [commitToServer], + ); + + // Load initial SCORM data & Initialize Standard Fields + useEffect(() => { + async function loadScormData() { + try { + const response = await fetch( + `/api/scorm/lesson/${lessonId}/runtime`, + ); + if (response.ok) { + const data = await response.json(); + scormState.current = data || { cmi: {} }; + + // Tell the SCO the context (Credit, Mode, Entry) + const hasSuspend = !!getNestedValue( + scormState.current, + "cmi.suspend_data", + ); + const entryValue = hasSuspend ? "resume" : "ab-initio"; + + // SCORM 1.2 + setNestedValue( + scormState.current, + "cmi.core.entry", + entryValue, + ); + setNestedValue( + scormState.current, + "cmi.core.lesson_mode", + "normal", + ); + setNestedValue( + scormState.current, + "cmi.core.credit", + "credit", + ); + + // SCORM 2004 + setNestedValue(scormState.current, "cmi.entry", entryValue); + setNestedValue(scormState.current, "cmi.mode", "normal"); + setNestedValue(scormState.current, "cmi.credit", "credit"); + + // Ensure default status is present for 1.2 if missing + if ( + !getNestedValue( + scormState.current, + "cmi.core.lesson_status", + ) + ) { + setNestedValue( + scormState.current, + "cmi.core.lesson_status", + "not attempted", + ); + } + // Ensure default status is present for 2004 if missing + if ( + !getNestedValue( + scormState.current, + "cmi.completion_status", + ) + ) { + setNestedValue( + scormState.current, + "cmi.completion_status", + "unknown", + ); // 2004 default is 'unknown' + } + } + } catch (err) { + console.error("Failed to load SCORM runtime data:", err); + } finally { + setIsDataLoaded(true); + } + } + loadScormData(); + }, [lessonId]); + + // Force immediate flush (no debounce) - used for Finish/Terminate + const forceFlush = useCallback(async () => { + if (pendingUpdates.current.size === 0) return; + const updates = Object.fromEntries(pendingUpdates.current); + pendingUpdates.current.clear(); + await fetch(`/api/scorm/lesson/${lessonId}/runtime`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ updates }), + }); + }, [lessonId]); + + // Fix B: Save on browser close/crash + useEffect(() => { + const onUnload = () => { + if (!sessionActive.current) return; + + setNestedValue(scormState.current, "cmi.core.exit", "suspend"); + pendingUpdates.current.set("cmi.core.exit", "suspend"); + + const payload = JSON.stringify({ + updates: Object.fromEntries(pendingUpdates.current), + }); + navigator.sendBeacon( + `/api/scorm/lesson/${lessonId}/runtime`, + payload, + ); + }; + + window.addEventListener("beforeunload", onUnload); + return () => window.removeEventListener("beforeunload", onUnload); + }, [lessonId]); + + // SCORM 1.2 API implementation - only expose after data is loaded + useEffect(() => { + if (!isDataLoaded) return; + + const API = { + LMSInitialize: () => { + sessionActive.current = true; + setLoading(false); + return "true"; + }, + LMSGetValue: (element: string) => getValue(element), + LMSSetValue: (element: string, value: string) => + setValue(element, value), + LMSCommit: () => { + commitToServer(); + return "true"; + }, + LMSFinish: async () => { + // SCORM 1.2: If status is 'not attempted', it becomes 'completed' + const status = getNestedValue( + scormState.current, + "cmi.core.lesson_status", + ); + if (status === "not attempted") { + setNestedValue( + scormState.current, + "cmi.core.lesson_status", + "completed", + ); + pendingUpdates.current.set( + "cmi.core.lesson_status", + "completed", + ); + } + + // Always suspend on exit for resume support + setNestedValue(scormState.current, "cmi.core.exit", "suspend"); + pendingUpdates.current.set("cmi.core.exit", "suspend"); + + await forceFlush(); + sessionActive.current = false; + return "true"; + }, + LMSGetLastError: () => "0", + LMSGetErrorString: () => "", + LMSGetDiagnostic: () => "", + }; + + // SCORM 2004 + const API_1484_11 = { + Initialize: () => { + sessionActive.current = true; + setLoading(false); + return "true"; + }, + GetValue: (element: string) => getValue(element), + SetValue: (element: string, value: string) => + setValue(element, value), + Commit: () => { + commitToServer(); + return "true"; + }, + Terminate: async () => { + // Always suspend on exit for resume support + setNestedValue(scormState.current, "cmi.exit", "suspend"); + pendingUpdates.current.set("cmi.exit", "suspend"); + + await forceFlush(); + sessionActive.current = false; + return "true"; + }, + GetLastError: () => "0", + GetErrorString: () => "", + GetDiagnostic: () => "", + }; + + // Expose APIs on window for SCORM content to find + (window as any).API = API; + (window as any).API_1484_11 = API_1484_11; + + return () => { + delete (window as any).API; + delete (window as any).API_1484_11; + }; + }, [isDataLoaded, getValue, setValue, commitToServer, forceFlush]); + + const handleIframeLoad = () => { + setLoading(false); + }; + + const handleIframeError = () => { + setError("Failed to load SCORM content"); + setLoading(false); + }; + + const handleRetry = () => { + setError(null); + setLoading(true); + if (iframeRef.current) { + iframeRef.current.src = iframeRef.current.src; + } + }; + + // Open in popup window + const openInPopup = useCallback(() => { + const url = `/api/scorm/lesson/${lessonId}/content/${launchUrl}`; + const width = window.screen.width * 0.9; + const height = window.screen.height * 0.9; + const left = (window.screen.width - width) / 2; + const top = (window.screen.height - height) / 2; + + window.open( + url, + "scorm_content", + `width=${width},height=${height},left=${left},top=${top},menubar=no,toolbar=no,location=no,status=no,resizable=yes,scrollbars=yes`, + ); + }, [lessonId, launchUrl]); + + if (error) { + return ( +
+

{error}

+ +
+ ); + } + + return ( +
+ {/* Toolbar with open in popup button */} +
+ +
+ {loading && ( +
+
+
+ )} + {isDataLoaded && ( +