627 lines
20 KiB
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)
|
|
}
|