293 lines
8.4 KiB
Go
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
|
|
}
|