285 lines
8.5 KiB
Go

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
}
}