package main import ( "errors" "net/http" "strings" "time" "github.com/go-chi/chi/v5" ) func (a *app) getLearnerScormRuntime(w http.ResponseWriter, r *http.Request) { userID, ok := a.learnerSubjectFromContext(r) if !ok { writeError(w, http.StatusUnauthorized, "missing learner subject") return } courseID := strings.TrimSpace(chi.URLParam(r, "id")) course, enrollment, _, access, err := a.loadLearnerScormRuntimeContext(r, userID, courseID) if err != nil { a.writeLearningNotFound(w, err, "SCORM course") return } if allowed, _ := access["allowed"].(bool); !allowed { writeError(w, http.StatusForbidden, stringValue(access["message"])) return } scoID := strings.TrimSpace(r.URL.Query().Get("scoId")) if scoID == "" { scoID = firstScormSCOID(course) } if scoID == "" || !scormCourseHasSCO(course, scoID) { writeError(w, http.StatusNotFound, "SCORM SCO not found") return } states, err := a.list("scorm_runtime_states", Doc{"userId": userID, "courseId": courseID, "scoId": scoID}, Sort{{Key: "updatedAt", Value: -1}}) if err != nil { writeError(w, http.StatusInternalServerError, "failed to load SCORM runtime") return } if len(states) == 0 { writeJSON(w, http.StatusOK, defaultScormRuntimeState(userID, courseID, stringValue(enrollment["id"]), scoID)) return } writeJSON(w, http.StatusOK, states[0]) } func (a *app) commitLearnerScormRuntime(w http.ResponseWriter, r *http.Request) { userID, ok := a.learnerSubjectFromContext(r) if !ok { writeError(w, http.StatusUnauthorized, "missing learner subject") return } courseID := strings.TrimSpace(chi.URLParam(r, "id")) course, enrollment, _, access, err := a.loadLearnerScormRuntimeContext(r, userID, courseID) if err != nil { a.writeLearningNotFound(w, err, "SCORM course") return } if allowed, _ := access["allowed"].(bool); !allowed { writeError(w, http.StatusForbidden, stringValue(access["message"])) return } var body map[string]any if err := decodeJSON(r, &body); err != nil { writeError(w, http.StatusBadRequest, "invalid SCORM runtime payload") return } scoID := strings.TrimSpace(stringValue(body["scoId"])) if scoID == "" { scoID = firstScormSCOID(course) } if scoID == "" || !scormCourseHasSCO(course, scoID) { writeError(w, http.StatusBadRequest, "scoId must identify a SCO in this course") return } states, err := a.list("scorm_runtime_states", Doc{"userId": userID, "courseId": courseID, "scoId": scoID}, Sort{{Key: "updatedAt", Value: -1}}) if err != nil { writeError(w, http.StatusInternalServerError, "failed to load SCORM runtime") return } state := defaultScormRuntimeState(userID, courseID, stringValue(enrollment["id"]), scoID) var existingID string if len(states) > 0 { existingID = stringValue(states[0]["id"]) for k, v := range states[0] { state[k] = v } } for _, field := range []string{"location", "suspendData", "lessonStatus", "completionStatus", "successStatus", "status"} { if value, ok := body[field].(string); ok { state[field] = strings.TrimSpace(value) } } for _, field := range []string{"score", "progress"} { if raw, exists := body[field]; exists { if value, ok := numericValue(raw); ok { if value < 0 { value = 0 } if value > 100 { value = 100 } state[field] = value } } } for _, field := range []string{"sessionTimeSeconds", "totalTimeSeconds"} { if raw, exists := body[field]; exists { if value, ok := numericValue(raw); ok { if value < 0 { value = 0 } state[field] = int64(value) } } } if rawValues, exists := body["values"]; exists { values, ok := asMap(rawValues) if !ok { writeError(w, http.StatusBadRequest, "values must be an object") return } merged := Doc{} if existing, ok := asMap(state["values"]); ok { for k, v := range existing { merged[k] = v } } for k, v := range values { merged[k] = v } state["values"] = merged } state["lastCommittedAt"] = time.Now().UnixMilli() if tenantID := tenantIDForLearningRequest(r); tenantID != "" { state["tenantId"] = tenantID } delete(state, "id") delete(state, "createdAt") delete(state, "updatedAt") var saved Doc if existingID != "" { saved, err = a.patch("scorm_runtime_states", existingID, state) } else { saved, err = a.create("scorm_runtime_states", state) } if err != nil { writeError(w, http.StatusInternalServerError, "failed to save SCORM runtime") return } summary, err := a.updateScormEnrollmentFromRuntime(course, enrollment, userID) if err != nil { writeError(w, http.StatusInternalServerError, "failed to update SCORM progress") return } writeJSON(w, http.StatusOK, Doc{"runtime": saved, "courseProgress": summary}) } func defaultScormRuntimeState(userID, courseID, enrollmentID, scoID string) Doc { return Doc{ "userId": userID, "courseId": courseID, "enrollmentId": enrollmentID, "scoId": scoID, "status": "initialized", "location": "", "suspendData": "", "lessonStatus": "incomplete", "completionStatus": "incomplete", "successStatus": "unknown", "score": 0, "progress": 0, "sessionTimeSeconds": 0, "totalTimeSeconds": 0, "values": Doc{}, } } func (a *app) loadLearnerScormRuntimeContext(r *http.Request, userID, courseID string) (Doc, Doc, []Doc, Doc, error) { course, err := a.getByID("scorm_courses", courseID) if err != nil { return nil, nil, nil, nil, err } enrollments, err := a.list("enrollments", Doc{"userId": userID, "courseId": courseID}, Sort{{Key: "updatedAt", Value: -1}}) if err != nil { return nil, nil, nil, nil, err } if len(enrollments) == 0 { return course, nil, nil, learnerAccessResult(false, "missing_enrollment", "You are not enrolled in this SCORM course."), nil } enrollment := enrollments[0] exports, err := a.list("scorm_exports", Doc{"courseId": courseID}, Sort{{Key: "createdAt", Value: -1}}) if err != nil { return nil, nil, nil, nil, err } entitled, reason, message, err := a.learnerEntitlementDecision(r, userID, resolveEntitlementKey(course, nil)) if err != nil { return nil, nil, nil, nil, err } return course, enrollment, exports, evaluateLearnerScormLaunch(course, enrollment, exports, entitled, reason, message), nil } func firstScormSCOID(course Doc) string { raw := course["scos"] switch list := raw.(type) { case []any: if len(list) > 0 { if item, ok := asMap(list[0]); ok { return stringValue(item["id"]) } } case List: if len(list) > 0 { if item, ok := asMap(list[0]); ok { return stringValue(item["id"]) } } } return "" } func scormCourseHasSCO(course Doc, scoID string) bool { raw := course["scos"] var list []any switch value := raw.(type) { case []any: list = value case List: list = []any(value) } for _, rawItem := range list { item, ok := asMap(rawItem) if ok && stringValue(item["id"]) == scoID { return true } } return false } func scormRuntimeComplete(state Doc) bool { completion := strings.ToLower(strings.TrimSpace(stringValue(state["completionStatus"]))) lesson := strings.ToLower(strings.TrimSpace(stringValue(state["lessonStatus"]))) return completion == "completed" || completion == "passed" || lesson == "completed" || lesson == "passed" || lesson == "failed" } func (a *app) updateScormEnrollmentFromRuntime(course, enrollment Doc, userID string) (Doc, error) { courseID := stringValue(course["id"]) states, err := a.list("scorm_runtime_states", Doc{"courseId": courseID, "userId": userID}, nil) if err != nil { return nil, err } total := 0 switch scos := course["scos"].(type) { case []any: total = len(scos) case List: total = len(scos) } completed := 0 scoreTotal := 0.0 scoreCount := 0 for _, state := range states { if scormRuntimeComplete(state) { completed++ } if score, ok := numericValue(state["score"]); ok && score > 0 { scoreTotal += score scoreCount++ } } percent := 0.0 if total > 0 { percent = float64(completed) * 100 / float64(total) } patch := Doc{"progress": percent} if scoreCount > 0 { patch["score"] = scoreTotal / float64(scoreCount) } if total > 0 && completed >= total { patch["status"] = "completed" patch["completedAt"] = time.Now().UnixMilli() } else if percent > 0 { patch["status"] = "in_progress" } if enrollment != nil && stringValue(enrollment["id"]) != "" { if _, err := a.patch("enrollments", stringValue(enrollment["id"]), patch); err != nil && !errors.Is(err, ErrNotFound) { return nil, err } } return Doc{"total": total, "completed": completed, "percent": percent, "score": patch["score"]}, nil }