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) }