learn-platform-deploy/Backend/cmd/api/learning_content.go

627 lines
20 KiB
Go

package main
import (
"errors"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
)
func tenantIDForLearningRequest(r *http.Request) string {
if r == nil {
return ""
}
tenant, _ := r.Context().Value(tenantContextKey).(tenantContext)
return strings.TrimSpace(tenant.ID)
}
func prepareCourseModulePayload(courseID string, payload Doc, partial bool) (Doc, error) {
if payload == nil {
payload = Doc{}
}
payload["courseId"] = strings.TrimSpace(courseID)
title, hasTitle, err := getOptionalString(payload, "title")
if err != nil {
return nil, errors.New("title must be a string")
}
if !partial && (!hasTitle || title == "") {
return nil, errors.New("title is required")
}
if hasTitle {
payload["title"] = title
}
if description, has, err := getOptionalString(payload, "description"); err != nil {
return nil, errors.New("description must be a string")
} else if has {
payload["description"] = description
}
if position, has, err := getOptionalInteger(payload, "position"); err != nil {
return nil, errors.New("position must be an integer")
} else if has {
if position < 0 {
return nil, errors.New("position must be greater than or equal to 0")
}
payload["position"] = position
} else if !partial {
payload["position"] = 0
}
status, hasStatus, err := getOptionalString(payload, "status")
if err != nil {
return nil, errors.New("status must be a string")
}
if !partial && !hasStatus {
status = "draft"
hasStatus = true
}
if hasStatus {
status = strings.ToLower(strings.TrimSpace(status))
switch status {
case "draft", "published", "archived":
payload["status"] = status
default:
return nil, errors.New("status must be one of: draft, published, archived")
}
}
return payload, nil
}
func prepareCourseLessonPayload(courseID, moduleID string, payload Doc, partial bool) (Doc, error) {
if payload == nil {
payload = Doc{}
}
payload["courseId"] = strings.TrimSpace(courseID)
payload["moduleId"] = strings.TrimSpace(moduleID)
title, hasTitle, err := getOptionalString(payload, "title")
if err != nil {
return nil, errors.New("title must be a string")
}
if !partial && (!hasTitle || title == "") {
return nil, errors.New("title is required")
}
if hasTitle {
payload["title"] = title
}
for _, field := range []string{"description", "content", "sourceUrl"} {
value, has, err := getOptionalString(payload, field)
if err != nil {
return nil, errors.New(field + " must be a string")
}
if has {
payload[field] = value
}
}
if position, has, err := getOptionalInteger(payload, "position"); err != nil {
return nil, errors.New("position must be an integer")
} else if has {
if position < 0 {
return nil, errors.New("position must be greater than or equal to 0")
}
payload["position"] = position
} else if !partial {
payload["position"] = 0
}
if duration, has, err := getOptionalInteger(payload, "durationMinutes"); err != nil {
return nil, errors.New("durationMinutes must be an integer")
} else if has {
if duration < 0 {
return nil, errors.New("durationMinutes must be greater than or equal to 0")
}
payload["durationMinutes"] = duration
}
contentType, hasType, err := getOptionalString(payload, "contentType")
if err != nil {
return nil, errors.New("contentType must be a string")
}
if !partial && !hasType {
contentType = "text"
hasType = true
}
if hasType {
contentType = strings.ToLower(strings.TrimSpace(contentType))
switch contentType {
case "text", "video", "link", "document":
payload["contentType"] = contentType
default:
return nil, errors.New("contentType must be one of: text, video, link, document")
}
}
status, hasStatus, err := getOptionalString(payload, "status")
if err != nil {
return nil, errors.New("status must be a string")
}
if !partial && !hasStatus {
status = "draft"
hasStatus = true
}
if hasStatus {
status = strings.ToLower(strings.TrimSpace(status))
switch status {
case "draft", "published", "archived":
payload["status"] = status
default:
return nil, errors.New("status must be one of: draft, published, archived")
}
}
return payload, nil
}
func (a *app) listLiveCourseModules(w http.ResponseWriter, r *http.Request) {
courseID := strings.TrimSpace(chi.URLParam(r, "id"))
if _, err := a.getByID("live_courses", courseID); err != nil {
a.writeLearningNotFound(w, err, "live course")
return
}
docs, err := a.list("course_modules", Doc{"courseId": courseID}, Sort{{Key: "position", Value: 1}, {Key: "createdAt", Value: 1}})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list course modules")
return
}
writeJSON(w, http.StatusOK, docs)
}
func (a *app) createLiveCourseModule(w http.ResponseWriter, r *http.Request) {
courseID := strings.TrimSpace(chi.URLParam(r, "id"))
if _, err := a.getByID("live_courses", courseID); err != nil {
a.writeLearningNotFound(w, err, "live course")
return
}
var body map[string]any
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid module payload")
return
}
doc, err := prepareCourseModulePayload(courseID, Doc(body), false)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if tenantID := tenantIDForLearningRequest(r); tenantID != "" {
doc["tenantId"] = tenantID
}
doc, err = a.create("course_modules", doc)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create course module")
return
}
writeJSON(w, http.StatusCreated, doc)
}
func (a *app) patchLiveCourseModule(w http.ResponseWriter, r *http.Request) {
courseID, moduleID := strings.TrimSpace(chi.URLParam(r, "id")), strings.TrimSpace(chi.URLParam(r, "moduleId"))
module, err := a.getCourseModule(courseID, moduleID)
if err != nil {
a.writeLearningNotFound(w, err, "course module")
return
}
var body map[string]any
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid module payload")
return
}
doc, err := prepareCourseModulePayload(courseID, Doc(body), true)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
_ = module
doc, err = a.patch("course_modules", moduleID, doc)
if err != nil {
a.writeLearningNotFound(w, err, "course module")
return
}
writeJSON(w, http.StatusOK, doc)
}
func (a *app) deleteLiveCourseModule(w http.ResponseWriter, r *http.Request) {
courseID, moduleID := strings.TrimSpace(chi.URLParam(r, "id")), strings.TrimSpace(chi.URLParam(r, "moduleId"))
if _, err := a.getCourseModule(courseID, moduleID); err != nil {
a.writeLearningNotFound(w, err, "course module")
return
}
if err := a.deleteByID("course_modules", moduleID); err != nil {
a.writeLearningNotFound(w, err, "course module")
return
}
w.WriteHeader(http.StatusNoContent)
}
func (a *app) listLiveCourseLessons(w http.ResponseWriter, r *http.Request) {
courseID, moduleID := strings.TrimSpace(chi.URLParam(r, "id")), strings.TrimSpace(chi.URLParam(r, "moduleId"))
if _, err := a.getCourseModule(courseID, moduleID); err != nil {
a.writeLearningNotFound(w, err, "course module")
return
}
docs, err := a.list("course_lessons", Doc{"courseId": courseID, "moduleId": moduleID}, Sort{{Key: "position", Value: 1}, {Key: "createdAt", Value: 1}})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list course lessons")
return
}
writeJSON(w, http.StatusOK, docs)
}
func (a *app) createLiveCourseLesson(w http.ResponseWriter, r *http.Request) {
courseID, moduleID := strings.TrimSpace(chi.URLParam(r, "id")), strings.TrimSpace(chi.URLParam(r, "moduleId"))
if _, err := a.getCourseModule(courseID, moduleID); err != nil {
a.writeLearningNotFound(w, err, "course module")
return
}
var body map[string]any
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid lesson payload")
return
}
doc, err := prepareCourseLessonPayload(courseID, moduleID, Doc(body), false)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if tenantID := tenantIDForLearningRequest(r); tenantID != "" {
doc["tenantId"] = tenantID
}
doc, err = a.create("course_lessons", doc)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create course lesson")
return
}
writeJSON(w, http.StatusCreated, doc)
}
func (a *app) patchLiveCourseLesson(w http.ResponseWriter, r *http.Request) {
courseID, moduleID, lessonID := strings.TrimSpace(chi.URLParam(r, "id")), strings.TrimSpace(chi.URLParam(r, "moduleId")), strings.TrimSpace(chi.URLParam(r, "lessonId"))
if _, err := a.getCourseLesson(courseID, moduleID, lessonID); err != nil {
a.writeLearningNotFound(w, err, "course lesson")
return
}
var body map[string]any
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid lesson payload")
return
}
doc, err := prepareCourseLessonPayload(courseID, moduleID, Doc(body), true)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
doc, err = a.patch("course_lessons", lessonID, doc)
if err != nil {
a.writeLearningNotFound(w, err, "course lesson")
return
}
writeJSON(w, http.StatusOK, doc)
}
func (a *app) deleteLiveCourseLesson(w http.ResponseWriter, r *http.Request) {
courseID, moduleID, lessonID := strings.TrimSpace(chi.URLParam(r, "id")), strings.TrimSpace(chi.URLParam(r, "moduleId")), strings.TrimSpace(chi.URLParam(r, "lessonId"))
if _, err := a.getCourseLesson(courseID, moduleID, lessonID); err != nil {
a.writeLearningNotFound(w, err, "course lesson")
return
}
if err := a.deleteByID("course_lessons", lessonID); err != nil {
a.writeLearningNotFound(w, err, "course lesson")
return
}
w.WriteHeader(http.StatusNoContent)
}
func (a *app) getCourseModule(courseID, moduleID string) (Doc, error) {
doc, err := a.getByID("course_modules", moduleID)
if err != nil {
return nil, err
}
if strings.TrimSpace(stringValue(doc["courseId"])) != strings.TrimSpace(courseID) {
return nil, ErrNotFound
}
return doc, nil
}
func (a *app) getCourseLesson(courseID, moduleID, lessonID string) (Doc, error) {
doc, err := a.getByID("course_lessons", lessonID)
if err != nil {
return nil, err
}
if strings.TrimSpace(stringValue(doc["courseId"])) != strings.TrimSpace(courseID) || strings.TrimSpace(stringValue(doc["moduleId"])) != strings.TrimSpace(moduleID) {
return nil, ErrNotFound
}
return doc, nil
}
func (a *app) getLearnerCourseContent(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.loadLearnerCourseContentAccess(r, userID, courseID)
if err != nil {
a.writeLearningNotFound(w, err, "live course")
return
}
if allowed, _ := access["allowed"].(bool); !allowed {
writeError(w, http.StatusForbidden, stringValue(access["message"]))
return
}
modules, err := a.list("course_modules", Doc{"courseId": courseID, "status": "published"}, Sort{{Key: "position", Value: 1}, {Key: "createdAt", Value: 1}})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load course modules")
return
}
lessons, err := a.list("course_lessons", Doc{"courseId": courseID, "status": "published"}, Sort{{Key: "position", Value: 1}, {Key: "createdAt", Value: 1}})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load course lessons")
return
}
progress, err := a.list("course_lesson_progress", Doc{"courseId": courseID, "userId": userID}, Sort{{Key: "updatedAt", Value: -1}})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load lesson progress")
return
}
progressByLesson := map[string]Doc{}
for _, item := range progress {
progressByLesson[stringValue(item["lessonId"])] = item
}
moduleItems := make([]Doc, 0, len(modules))
for _, module := range modules {
moduleID := stringValue(module["id"])
moduleLessons := []Doc{}
for _, lesson := range lessons {
if stringValue(lesson["moduleId"]) != moduleID {
continue
}
item := Doc{"lesson": lesson}
if p := progressByLesson[stringValue(lesson["id"])]; p != nil {
item["progress"] = p
}
moduleLessons = append(moduleLessons, item)
}
moduleItems = append(moduleItems, Doc{"module": module, "lessons": moduleLessons})
}
writeJSON(w, http.StatusOK, Doc{
"course": course,
"enrollment": enrollment,
"modules": moduleItems,
"progress": courseLessonProgressSummary(lessons, progress),
})
}
func (a *app) completeLearnerCourseLesson(w http.ResponseWriter, r *http.Request) {
userID, ok := a.learnerSubjectFromContext(r)
if !ok {
writeError(w, http.StatusUnauthorized, "missing learner subject")
return
}
courseID, lessonID := strings.TrimSpace(chi.URLParam(r, "id")), strings.TrimSpace(chi.URLParam(r, "lessonId"))
_, enrollment, access, err := a.loadLearnerCourseContentAccess(r, userID, courseID)
if err != nil {
a.writeLearningNotFound(w, err, "live course")
return
}
if allowed, _ := access["allowed"].(bool); !allowed {
writeError(w, http.StatusForbidden, stringValue(access["message"]))
return
}
lesson, err := a.getByID("course_lessons", lessonID)
if err != nil || stringValue(lesson["courseId"]) != courseID || !strings.EqualFold(stringValue(lesson["status"]), "published") {
writeError(w, http.StatusNotFound, "course lesson not found")
return
}
now := time.Now().UnixMilli()
filter := Doc{"courseId": courseID, "lessonId": lessonID, "userId": userID}
existing, err := a.list("course_lesson_progress", filter, Sort{{Key: "updatedAt", Value: -1}})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load lesson progress")
return
}
payload := Doc{
"courseId": courseID,
"moduleId": lesson["moduleId"],
"lessonId": lessonID,
"userId": userID,
"status": "completed",
"progress": 100,
"completedAt": now,
"lastViewedAt": now,
}
if tenantID := tenantIDForLearningRequest(r); tenantID != "" {
payload["tenantId"] = tenantID
}
var saved Doc
if len(existing) > 0 {
saved, err = a.patch("course_lesson_progress", stringValue(existing[0]["id"]), payload)
} else {
payload["startedAt"] = now
saved, err = a.create("course_lesson_progress", payload)
}
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to save lesson progress")
return
}
lessons, _ := a.list("course_lessons", Doc{"courseId": courseID, "status": "published"}, nil)
progress, _ := a.list("course_lesson_progress", Doc{"courseId": courseID, "userId": userID}, nil)
summary := courseLessonProgressSummary(lessons, progress)
percent, _ := numericValue(summary["percent"])
if enrollment != nil {
patch := Doc{"progress": percent}
if percent >= 100 {
patch["status"] = "completed"
patch["completedAt"] = now
}
_, _ = a.patch("live_course_enrollments", stringValue(enrollment["id"]), patch)
}
writeJSON(w, http.StatusOK, Doc{"lessonProgress": saved, "courseProgress": summary})
}
func courseLessonProgressSummary(lessons, progress []Doc) Doc {
total := len(lessons)
completedIDs := map[string]struct{}{}
for _, item := range progress {
if strings.EqualFold(stringValue(item["status"]), "completed") {
completedIDs[stringValue(item["lessonId"])] = struct{}{}
}
}
completed := 0
for _, lesson := range lessons {
if _, ok := completedIDs[stringValue(lesson["id"])]; ok {
completed++
}
}
percent := 0.0
if total > 0 {
percent = float64(completed) * 100 / float64(total)
}
return Doc{"total": total, "completed": completed, "percent": percent}
}
func (a *app) listLearnerCourseAssets(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.loadLearnerCourseContentAccess(r, userID, courseID)
if err != nil {
a.writeLearningNotFound(w, err, "live course")
return
}
if allowed, _ := access["allowed"].(bool); !allowed {
writeError(w, http.StatusForbidden, stringValue(access["message"]))
return
}
ids, _, _ := normalizeStringSlice(course["assetIds"])
if len(ids) == 0 {
writeJSON(w, http.StatusOK, []Doc{})
return
}
assets, err := a.list("assets", Doc{"id": Doc{"$in": ids}, "status": "active"}, Sort{{Key: "updatedAt", Value: -1}})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load course assets")
return
}
out := []Doc{}
for _, asset := range assets {
allowed, err := a.canLearnerAccessCourseAsset(r, userID, enrollment, asset)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to evaluate asset access")
return
}
if !allowed {
continue
}
allowDownload, _ := asset["allowDownload"].(bool)
out = append(out, Doc{
"id": asset["id"], "title": asset["title"], "description": asset["description"],
"fileName": asset["fileName"], "visibility": asset["visibility"],
"protectionLevel": asset["protectionLevel"], "allowDownload": allowDownload,
})
}
writeJSON(w, http.StatusOK, out)
}
func (a *app) accessLearnerCourseAsset(w http.ResponseWriter, r *http.Request) {
userID, ok := a.learnerSubjectFromContext(r)
if !ok {
writeError(w, http.StatusUnauthorized, "missing learner subject")
return
}
courseID, assetID := strings.TrimSpace(chi.URLParam(r, "id")), strings.TrimSpace(chi.URLParam(r, "assetId"))
course, enrollment, access, err := a.loadLearnerCourseContentAccess(r, userID, courseID)
if err != nil {
a.writeLearningNotFound(w, err, "live course")
return
}
if allowed, _ := access["allowed"].(bool); !allowed {
writeError(w, http.StatusForbidden, stringValue(access["message"]))
return
}
ids, _, _ := normalizeStringSlice(course["assetIds"])
linked := false
for _, id := range ids {
if id == assetID {
linked = true
break
}
}
if !linked {
writeError(w, http.StatusNotFound, "course asset not found")
return
}
asset, err := a.getByID("assets", assetID)
if err != nil {
a.writeLearningNotFound(w, err, "asset")
return
}
allowed, err := a.canLearnerAccessCourseAsset(r, userID, enrollment, asset)
if err != nil || !allowed {
writeError(w, http.StatusForbidden, "asset access denied")
return
}
allowDownload, _ := asset["allowDownload"].(bool)
sourceURL := strings.TrimSpace(stringValue(asset["sourceUrl"]))
if !allowDownload || sourceURL == "" {
writeError(w, http.StatusForbidden, "asset download is not enabled")
return
}
event := Doc{"assetId": assetID, "userId": userID, "eventType": "course_download", "submittedAt": time.Now().UnixMilli(), "metadata": Doc{"courseId": courseID}}
if tenantID := tenantIDForLearningRequest(r); tenantID != "" {
event["tenantId"] = tenantID
}
_, _ = a.create("asset_access_events", event)
writeJSON(w, http.StatusOK, Doc{"url": sourceURL})
}
func (a *app) canLearnerAccessCourseAsset(r *http.Request, userID string, enrollment, asset Doc) (bool, error) {
visibility := strings.ToLower(strings.TrimSpace(stringValue(asset["visibility"])))
switch visibility {
case "", "public", "authenticated":
return true, nil
case "enrolled":
return enrollment != nil, nil
case "entitled":
key := strings.TrimSpace(stringValue(asset["entitlementKey"]))
if key == "" {
return false, nil
}
return a.hasActiveEntitlement(userID, key, time.Now().UTC())
case "instructor":
claims, _ := r.Context().Value(userClaimsKey).(*accessClaims)
return hasRole(claims, "instructor") || hasRole(claims, "admin"), nil
case "admin":
claims, _ := r.Context().Value(userClaimsKey).(*accessClaims)
return hasRole(claims, "admin"), nil
default:
return false, nil
}
}
func (a *app) loadLearnerCourseContentAccess(r *http.Request, userID, courseID string) (Doc, Doc, Doc, error) {
course, err := a.getByID("live_courses", courseID)
if err != nil {
return nil, nil, nil, err
}
enrollments, err := a.list("live_course_enrollments", Doc{
"userId": userID,
"$or": List{Doc{"liveCourseId": courseID}, Doc{"courseId": courseID}},
"status": Doc{"$in": List{"enrolled", "completed"}},
}, Sort{{Key: "updatedAt", Value: -1}})
if err != nil {
return nil, nil, nil, err
}
enrollment := firstDocument(enrollments)
entitled, reason, message, err := a.learnerEntitlementDecision(r, userID, resolveEntitlementKey(course, nil))
if err != nil {
return nil, nil, nil, err
}
return course, enrollment, evaluateLearnerAccess(course, nil, enrollment, entitled, reason, message), nil
}
func (a *app) writeLearningNotFound(w http.ResponseWriter, err error, resource string) {
if errors.Is(err, ErrNotFound) {
writeError(w, http.StatusNotFound, resource+" not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to load "+resource)
}