285 lines
8.5 KiB
Go
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
|
|
}
|
|
}
|