293 lines
8.4 KiB
Go

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
}