package main import ( "errors" "net/http" "strings" "github.com/go-chi/chi/v5" ) func (a *app) scormLearnerMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { claims, _ := r.Context().Value(userClaimsKey).(*accessClaims) if claims == nil { writeError(w, http.StatusUnauthorized, "no authenticated user") return } if !hasRole(claims, "learner") && !hasRole(claims, "admin") && !hasRole(claims, "instructor") { writeError(w, http.StatusForbidden, "insufficient role for learner scorm access") return } next.ServeHTTP(w, r) }) } func (a *app) listLearnerScormCourses(w http.ResponseWriter, r *http.Request) { if a.db == nil { writeError(w, http.StatusServiceUnavailable, "database not configured") return } userID, ok := a.learnerSubjectFromContext(r) if !ok { writeError(w, http.StatusUnauthorized, "missing learner subject") return } enrollments, err := a.list("enrollments", Doc{"userId": userID}, Sort{{Key: "updatedAt", Value: -1}, {Key: "createdAt", Value: -1}}) if err != nil { writeError(w, http.StatusInternalServerError, "failed to list learner enrollments") return } courseIDs := make(map[string]struct{}) filteredEnrollments := make([]Doc, 0, len(enrollments)) for _, enrollment := range enrollments { courseID, _ := asString(enrollment["courseId"]) if courseID == "" { continue } courseType, _ := asString(enrollment["courseType"]) if courseType != "" && !strings.EqualFold(courseType, "scorm") { continue } filteredEnrollments = append(filteredEnrollments, enrollment) courseIDs[courseID] = struct{}{} } if len(courseIDs) == 0 { writeJSON(w, http.StatusOK, []Doc{}) return } courses, err := a.list("scorm_courses", Doc{"id": Doc{"$in": mapKeys(courseIDs)}}, Sort{{Key: "updatedAt", Value: -1}}) if err != nil { writeError(w, http.StatusInternalServerError, "failed to load scorm courses") return } courseByID := indexByID(courses) exports, err := a.list("scorm_exports", Doc{"courseId": Doc{"$in": mapKeys(courseIDs)}}, Sort{{Key: "createdAt", Value: -1}}) if err != nil { writeError(w, http.StatusInternalServerError, "failed to load scorm exports") return } exportsByCourse := make(map[string][]Doc) for _, exportDoc := range exports { courseID, _ := asString(exportDoc["courseId"]) if courseID == "" { continue } exportsByCourse[courseID] = append(exportsByCourse[courseID], exportDoc) } items := make([]Doc, 0, len(filteredEnrollments)) seenCourse := make(map[string]struct{}) for _, enrollment := range filteredEnrollments { courseID, _ := asString(enrollment["courseId"]) if courseID == "" { continue } if _, exists := seenCourse[courseID]; exists { continue } course := courseByID[courseID] if course == nil { continue } hasEntitlement, entitlementReason, entitlementMessage, err := a.learnerEntitlementDecision(r, userID, resolveEntitlementKey(course, nil)) if err != nil { writeError(w, http.StatusInternalServerError, "failed to evaluate learner entitlement") return } seenCourse[courseID] = struct{}{} items = append(items, Doc{ "course": course, "enrollment": enrollment, "launchState": evaluateLearnerScormLaunch(course, enrollment, exportsByCourse[courseID], hasEntitlement, entitlementReason, entitlementMessage), }) } writeJSON(w, http.StatusOK, items) } func (a *app) getLearnerScormLaunch(w http.ResponseWriter, r *http.Request) { if a.db == nil { writeError(w, http.StatusServiceUnavailable, "database not configured") return } userID, ok := a.learnerSubjectFromContext(r) if !ok { writeError(w, http.StatusUnauthorized, "missing learner subject") return } courseID := strings.TrimSpace(chi.URLParam(r, "id")) if courseID == "" { writeError(w, http.StatusBadRequest, "scorm course id is required") return } course, err := a.getByID("scorm_courses", courseID) if err != nil { if errors.Is(err, ErrNotFound) { writeError(w, http.StatusNotFound, "scorm course not found") return } writeError(w, http.StatusInternalServerError, "failed to load scorm course") return } enrollments, err := a.list("enrollments", Doc{"userId": userID, "courseId": courseID}, Sort{{Key: "updatedAt", Value: -1}, {Key: "createdAt", Value: -1}}) if err != nil { writeError(w, http.StatusInternalServerError, "failed to load learner enrollment") return } enrollment := firstDocument(enrollments) exports, err := a.list("scorm_exports", Doc{"courseId": courseID}, Sort{{Key: "createdAt", Value: -1}}) if err != nil { writeError(w, http.StatusInternalServerError, "failed to load scorm exports") return } hasEntitlement, entitlementReason, entitlementMessage, err := a.learnerEntitlementDecision(r, userID, resolveEntitlementKey(course, nil)) if err != nil { writeError(w, http.StatusInternalServerError, "failed to evaluate learner entitlement") return } launchState := evaluateLearnerScormLaunch(course, enrollment, exports, hasEntitlement, entitlementReason, entitlementMessage) writeJSON(w, http.StatusOK, Doc{ "course": course, "enrollment": enrollment, "launchState": launchState, }) } func evaluateLearnerScormLaunch(course, enrollment Doc, exports []Doc, entitlementAllowed bool, entitlementReason, entitlementMessage string) Doc { if course == nil { return Doc{ "allowed": false, "state": "locked", "reason": "missing_course", "message": "SCORM course was not found.", "progressNote": "Progress sync is unavailable because this SCORM course could not be loaded.", } } if enrollment == nil { return Doc{ "allowed": false, "state": "locked", "reason": "missing_enrollment", "message": "You are not currently enrolled in this SCORM course.", "progressNote": "Resume is unavailable until enrollment is active.", } } status, _ := asString(enrollment["status"]) if !isLearnerScormEnrollmentActive(status) { return Doc{ "allowed": false, "state": "locked", "reason": "inactive_enrollment", "message": "Your enrollment is not active for SCORM launch.", "progressNote": "Resume is unavailable while enrollment is inactive.", } } if !entitlementAllowed { if strings.TrimSpace(entitlementReason) == "" { entitlementReason = "missing_entitlement" } if strings.TrimSpace(entitlementMessage) == "" { entitlementMessage = "Your entitlement is missing or inactive for this SCORM course." } return Doc{ "allowed": false, "state": "locked", "reason": entitlementReason, "message": entitlementMessage, "progressNote": "Launch is unavailable until the required entitlement is active.", } } if !hasScormCoursePackage(course) { return Doc{ "allowed": false, "state": "missing_package", "reason": "missing_package", "message": "This SCORM course has no package content to launch yet.", "progressNote": "Resume will be available after content and runtime tracking are connected.", } } if len(exports) == 0 { return Doc{ "allowed": false, "state": "export_not_ready", "reason": "export_not_ready", "message": "A SCORM export has not been generated for this course yet.", "progressNote": "Resume placeholders remain visible after an export is available.", } } courseID, _ := asString(course["id"]) exportID, _ := asString(exports[0]["id"]) launchURL := "/api/scorm/" + courseID + "/exports" if exportID != "" { launchURL += "/" + exportID } resumeEligible := strings.EqualFold(status, "in_progress") || strings.EqualFold(status, "completed") progressNote := "Resume will use your last saved SCORM location once runtime tracking is fully integrated." if !resumeEligible { progressNote = "Progress tracking is not yet connected; start now and a resume point will appear when available." } return Doc{ "allowed": true, "state": "ready", "reason": "", "message": "SCORM package is ready to launch.", "launchUrl": launchURL, "resumeAvailable": resumeEligible, "progressNote": progressNote, } } func isLearnerScormEnrollmentActive(status string) bool { normalized := strings.TrimSpace(strings.ToLower(status)) switch normalized { case "", "not_started", "in_progress", "completed", "failed", "enrolled": return true default: return false } } func hasScormCoursePackage(course Doc) bool { raw := course["scos"] switch value := raw.(type) { case []any: return len(value) > 0 case List: return len(value) > 0 default: return false } }