package main import ( "errors" "net/http" "strings" "time" "github.com/go-chi/chi/v5" ) const ( liveCourseStatusDraft = "draft" liveCourseStatusScheduled = "scheduled" liveCourseStatusPublished = "published" liveCourseStatusArchived = "archived" ) func (a *app) liveCourseManagerMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { claims, _ := r.Context().Value(userClaimsKey).(*accessClaims) if claims == nil { writeError(w, http.StatusUnauthorized, "no authenticated user") return } if !hasRole(claims, "admin") && !hasRole(claims, "instructor") { writeError(w, http.StatusForbidden, "insufficient role for live course management") return } next.ServeHTTP(w, r) }) } func (a *app) liveCourseLearnerMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { claims, _ := r.Context().Value(userClaimsKey).(*accessClaims) if claims == nil { writeError(w, http.StatusUnauthorized, "no authenticated user") return } if !hasRole(claims, "learner") && !hasRole(claims, "admin") && !hasRole(claims, "instructor") { writeError(w, http.StatusForbidden, "insufficient role for learner live course access") return } next.ServeHTTP(w, r) }) } func (a *app) listLearnerLiveCourses(w http.ResponseWriter, r *http.Request) { if a.db == nil { writeError(w, http.StatusServiceUnavailable, "database not configured") return } userID, ok := a.learnerSubjectFromContext(r) if !ok { writeError(w, http.StatusUnauthorized, "missing learner subject") return } enrollments, err := a.list("live_course_enrollments", Doc{ "userId": userID, "status": Doc{"$in": List{"enrolled", "completed"}}, }, Sort{{Key: "updatedAt", Value: -1}}) if err != nil { writeError(w, http.StatusInternalServerError, "failed to list learner enrollments") return } if len(enrollments) == 0 { writeJSON(w, http.StatusOK, []Doc{}) return } courseIDs := make(map[string]struct{}) cohortIDs := make(map[string]struct{}) for _, enrollment := range enrollments { if courseID, ok := courseIDFromDocument(enrollment); ok { courseIDs[courseID] = struct{}{} } if cohortID, ok := asString(enrollment["cohortId"]); ok { cohortIDs[cohortID] = struct{}{} } } courses, err := a.list("live_courses", Doc{"id": Doc{"$in": mapKeys(courseIDs)}}, Sort{{Key: "updatedAt", Value: -1}}) if err != nil { writeError(w, http.StatusInternalServerError, "failed to load live courses") return } cohorts, err := a.list("live_course_cohorts", Doc{"id": Doc{"$in": mapKeys(cohortIDs)}}, Sort{{Key: "startsAt", Value: 1}}) if err != nil { writeError(w, http.StatusInternalServerError, "failed to load cohorts") return } courseByID := indexByID(courses) cohortByID := indexByID(cohorts) now := time.Now().UTC().UnixMilli() sessions, err := a.list("live_course_sessions", Doc{ "$or": List{ Doc{"liveCourseId": Doc{"$in": mapKeys(courseIDs)}}, Doc{"courseId": Doc{"$in": mapKeys(courseIDs)}}, }, "cohortId": Doc{"$in": mapKeys(cohortIDs)}, "status": Doc{"$in": List{"scheduled", "live"}}, "startsAt": Doc{"$gte": now}, }, Sort{{Key: "startsAt", Value: 1}}) if err != nil { writeError(w, http.StatusInternalServerError, "failed to load sessions") return } upcomingByCourseCohort := make(map[string][]Doc) for _, session := range sessions { courseID, _ := courseIDFromDocument(session) cohortID, _ := asString(session["cohortId"]) key := compositeScopeKey(courseID, cohortID) upcomingByCourseCohort[key] = append(upcomingByCourseCohort[key], session) } items := make([]Doc, 0, len(enrollments)) for _, enrollment := range enrollments { courseID, _ := courseIDFromDocument(enrollment) cohortID, _ := asString(enrollment["cohortId"]) course := courseByID[courseID] cohort := cohortByID[cohortID] entitlementAllowed, entitlementReason, entitlementMessage, err := a.learnerEntitlementDecision(r, userID, resolveEntitlementKey(course, cohort)) if err != nil { writeError(w, http.StatusInternalServerError, "failed to evaluate learner entitlement") return } access := evaluateLearnerAccess(course, cohort, enrollment, entitlementAllowed, entitlementReason, entitlementMessage) items = append(items, Doc{ "course": course, "cohort": cohort, "enrollment": enrollment, "upcomingSessions": upcomingByCourseCohort[compositeScopeKey(courseID, cohortID)], "access": access, }) } writeJSON(w, http.StatusOK, items) } func (a *app) getLearnerLiveCourse(w http.ResponseWriter, r *http.Request) { if a.db == nil { writeError(w, http.StatusServiceUnavailable, "database not configured") return } userID, ok := a.learnerSubjectFromContext(r) if !ok { writeError(w, http.StatusUnauthorized, "missing learner subject") return } courseID := strings.TrimSpace(chi.URLParam(r, "id")) if courseID == "" { writeError(w, http.StatusBadRequest, "live course id is required") return } course, err := a.getByID("live_courses", courseID) if err != nil { if errors.Is(err, ErrNotFound) { writeError(w, http.StatusNotFound, "live course not found") return } writeError(w, http.StatusInternalServerError, "failed to load live course") return } 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 { writeError(w, http.StatusInternalServerError, "failed to load learner enrollments") return } if len(enrollments) == 0 && !isPublicLiveCourse(course, time.Now().UTC()) { writeError(w, http.StatusNotFound, "live course not found") return } cohortIDs := make(map[string]struct{}) for _, enrollment := range enrollments { if cohortID, ok := asString(enrollment["cohortId"]); ok { cohortIDs[cohortID] = struct{}{} } } cohorts := []Doc{} if len(cohortIDs) > 0 { cohorts, err = a.list("live_course_cohorts", Doc{"id": Doc{"$in": mapKeys(cohortIDs)}}, Sort{{Key: "startsAt", Value: 1}}) if err != nil { writeError(w, http.StatusInternalServerError, "failed to load cohorts") return } } entitlementAllowed, entitlementReason, entitlementMessage, err := a.learnerEntitlementDecision(r, userID, resolveEntitlementKey(course, nil)) if err != nil { writeError(w, http.StatusInternalServerError, "failed to evaluate learner entitlement") return } access := evaluateLearnerAccess(course, nil, firstDocument(enrollments), entitlementAllowed, entitlementReason, entitlementMessage) writeJSON(w, http.StatusOK, Doc{ "course": course, "enrollments": enrollments, "cohorts": cohorts, "access": access, }) } func (a *app) listLearnerLiveCourseSessions(w http.ResponseWriter, r *http.Request) { if a.db == nil { writeError(w, http.StatusServiceUnavailable, "database not configured") return } userID, ok := a.learnerSubjectFromContext(r) if !ok { writeError(w, http.StatusUnauthorized, "missing learner subject") return } courseID := strings.TrimSpace(chi.URLParam(r, "id")) if courseID == "" { writeError(w, http.StatusBadRequest, "live course id is required") return } course, err := a.getByID("live_courses", courseID) if err != nil { if errors.Is(err, ErrNotFound) { writeError(w, http.StatusNotFound, "live course not found") return } writeError(w, http.StatusInternalServerError, "failed to load live course") return } 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 { writeError(w, http.StatusInternalServerError, "failed to load learner enrollments") return } if len(enrollments) == 0 && !isPublicLiveCourse(course, time.Now().UTC()) { writeError(w, http.StatusNotFound, "live course not found") return } cohorts, err := a.list("live_course_cohorts", Doc{"$or": List{Doc{"liveCourseId": courseID}, Doc{"courseId": courseID}}}, Sort{{Key: "startsAt", Value: 1}}) if err != nil { writeError(w, http.StatusInternalServerError, "failed to load cohorts") return } cohortByID := indexByID(cohorts) enrollmentByCohort := indexEnrollmentsByCohort(enrollments) now := time.Now().UTC().UnixMilli() sessions, err := a.list("live_course_sessions", Doc{ "$or": List{Doc{"liveCourseId": courseID}, Doc{"courseId": courseID}}, "status": Doc{"$in": List{"scheduled", "live"}}, "startsAt": Doc{"$gte": now}, }, Sort{{Key: "startsAt", Value: 1}}) if err != nil { writeError(w, http.StatusInternalServerError, "failed to load sessions") return } items := make([]Doc, 0, len(sessions)) for _, session := range sessions { cohortID, _ := asString(session["cohortId"]) cohort := cohortByID[cohortID] enrollment := enrollmentByCohort[cohortID] entitlementAllowed, entitlementReason, entitlementMessage, err := a.learnerEntitlementDecision(r, userID, resolveEntitlementKey(course, cohort)) if err != nil { writeError(w, http.StatusInternalServerError, "failed to evaluate learner entitlement") return } access := evaluateLearnerSessionAccess(course, cohort, session, enrollment, entitlementAllowed, entitlementReason, entitlementMessage) items = append(items, Doc{ "session": session, "cohort": cohort, "access": access, }) } writeJSON(w, http.StatusOK, items) } func (a *app) getLearnerLiveCourseSession(w http.ResponseWriter, r *http.Request) { if a.db == nil { writeError(w, http.StatusServiceUnavailable, "database not configured") return } userID, ok := a.learnerSubjectFromContext(r) if !ok { writeError(w, http.StatusUnauthorized, "missing learner subject") return } courseID := strings.TrimSpace(chi.URLParam(r, "id")) sessionID := strings.TrimSpace(chi.URLParam(r, "sessionId")) if courseID == "" || sessionID == "" { writeError(w, http.StatusBadRequest, "live course id and session id are required") return } course, session, cohort, enrollment, err := a.loadLearnerSessionContext(userID, courseID, sessionID) if err != nil { a.writeLearnerSessionContextError(w, err) return } entitlementAllowed, entitlementReason, entitlementMessage, err := a.learnerEntitlementDecision(r, userID, resolveEntitlementKey(course, cohort)) if err != nil { writeError(w, http.StatusInternalServerError, "failed to evaluate learner entitlement") return } access := evaluateLearnerSessionAccess(course, cohort, session, enrollment, entitlementAllowed, entitlementReason, entitlementMessage) writeJSON(w, http.StatusOK, Doc{ "course": course, "cohort": cohort, "session": session, "enrollment": enrollment, "access": access, }) } func (a *app) joinLearnerLiveCourseSession(w http.ResponseWriter, r *http.Request) { if a.db == nil { writeError(w, http.StatusServiceUnavailable, "database not configured") return } userID, ok := a.learnerSubjectFromContext(r) if !ok { writeError(w, http.StatusUnauthorized, "missing learner subject") return } courseID := strings.TrimSpace(chi.URLParam(r, "id")) sessionID := strings.TrimSpace(chi.URLParam(r, "sessionId")) if courseID == "" || sessionID == "" { writeError(w, http.StatusBadRequest, "live course id and session id are required") return } course, session, cohort, enrollment, err := a.loadLearnerSessionContext(userID, courseID, sessionID) if err != nil { a.writeLearnerSessionContextError(w, err) return } entitlementAllowed, entitlementReason, entitlementMessage, err := a.learnerEntitlementDecision(r, userID, resolveEntitlementKey(course, cohort)) if err != nil { writeError(w, http.StatusInternalServerError, "failed to evaluate learner entitlement") return } access := evaluateLearnerSessionAccess(course, cohort, session, enrollment, entitlementAllowed, entitlementReason, entitlementMessage) allowed, _ := access["allowed"].(bool) if !allowed { message, _ := access["message"].(string) if message == "" { message = "session is locked" } writeError(w, http.StatusForbidden, message) return } meetingURL, _ := asString(session["meetingUrl"]) writeJSON(w, http.StatusOK, learnerLiveCourseJoinResponse{ Allowed: true, CourseID: courseID, SessionID: sessionID, JoinURL: meetingURL, }) } func (a *app) listLiveCourses(w http.ResponseWriter, r *http.Request) { a.listCollection("live_courses", w) } func (a *app) listPublicLiveCourses(w http.ResponseWriter, r *http.Request) { if a.db == nil { writeError(w, http.StatusServiceUnavailable, "database not configured") return } docs, err := a.list("live_courses", Doc{"status": Doc{"$in": List{liveCourseStatusPublished, liveCourseStatusScheduled}}}, Sort{{Key: "updatedAt", Value: -1}, {Key: "createdAt", Value: -1}}) if err != nil { writeError(w, http.StatusInternalServerError, "failed to list live courses") return } publicCourses := make([]Doc, 0, len(docs)) for _, doc := range docs { if isPublicLiveCourse(doc, time.Now().UTC()) { publicCourses = append(publicCourses, doc) } } writeJSON(w, http.StatusOK, publicCourses) } func (a *app) getPublicLiveCourseBySlug(w http.ResponseWriter, r *http.Request) { if a.db == nil { writeError(w, http.StatusServiceUnavailable, "database not configured") return } doc, err := a.getPublicLiveCourseBySlugOrNotFound(strings.TrimSpace(chi.URLParam(r, "slug"))) if err != nil { if errors.Is(err, ErrNotFound) { writeError(w, http.StatusNotFound, "live course not found") return } writeError(w, http.StatusInternalServerError, "failed to load live course") return } writeJSON(w, http.StatusOK, doc) } func (a *app) listPublicLiveCourseCohortsBySlug(w http.ResponseWriter, r *http.Request) { if a.db == nil { writeError(w, http.StatusServiceUnavailable, "database not configured") return } course, err := a.getPublicLiveCourseBySlugOrNotFound(strings.TrimSpace(chi.URLParam(r, "slug"))) if err != nil { if errors.Is(err, ErrNotFound) { writeError(w, http.StatusNotFound, "live course not found") return } writeError(w, http.StatusInternalServerError, "failed to load live course") return } liveCourseID, _ := asString(course["id"]) if liveCourseID == "" { writeError(w, http.StatusNotFound, "live course not found") return } docs, err := a.list("live_course_cohorts", Doc{"$or": List{Doc{"liveCourseId": liveCourseID}, Doc{"courseId": liveCourseID}}}, Sort{{Key: "startsAt", Value: 1}, {Key: "updatedAt", Value: -1}}) if err != nil { writeError(w, http.StatusInternalServerError, "failed to list cohorts") return } cohorts := make([]Doc, 0, len(docs)) for _, doc := range docs { if isOpenPublicLiveCourseCohort(doc, time.Now().UTC()) { cohorts = append(cohorts, doc) } } writeJSON(w, http.StatusOK, cohorts) } func (a *app) createLiveCourse(w http.ResponseWriter, r *http.Request) { var payload liveCourseUpsertRequest if err := decodeJSON(r, &payload); err != nil { writeError(w, http.StatusBadRequest, "invalid payload") return } doc, err := prepareLiveCoursePayload(payload.Doc(), false) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } doc, err = a.create("live_courses", doc) if err != nil { writeError(w, http.StatusInternalServerError, "failed to create resource") return } writeJSON(w, http.StatusCreated, doc) } func (a *app) getLiveCourse(w http.ResponseWriter, r *http.Request) { a.getCollection("live_courses", w, r) } func (a *app) patchLiveCourse(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") var patch liveCourseUpsertRequest if err := decodeJSON(r, &patch); err != nil { writeError(w, http.StatusBadRequest, "invalid patch payload") return } doc, err := prepareLiveCoursePayload(patch.Doc(), true) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } doc, err = a.patch("live_courses", id, doc) if err != nil { if errors.Is(err, ErrNotFound) { writeError(w, http.StatusNotFound, "resource not found") return } writeError(w, http.StatusInternalServerError, "failed to update resource") return } writeJSON(w, http.StatusOK, doc) } func (a *app) deleteLiveCourse(w http.ResponseWriter, r *http.Request) { a.deleteCollection("live_courses", w, r) } func (a *app) listLiveCourseCohorts(w http.ResponseWriter, r *http.Request) { courseID := strings.TrimSpace(chi.URLParam(r, "id")) if courseID == "" { writeError(w, http.StatusBadRequest, "live course id is required") return } docs, err := a.list("live_course_cohorts", Doc{"liveCourseId": courseID}, Sort{{Key: "startsAt", Value: 1}, {Key: "updatedAt", Value: -1}}) if err != nil { writeError(w, http.StatusInternalServerError, "failed to list cohorts") return } writeJSON(w, http.StatusOK, docs) } func (a *app) createLiveCourseCohort(w http.ResponseWriter, r *http.Request) { courseID := strings.TrimSpace(chi.URLParam(r, "id")) if courseID == "" { writeError(w, http.StatusBadRequest, "live course id is required") return } if _, err := a.getByID("live_courses", courseID); err != nil { if errors.Is(err, ErrNotFound) { writeError(w, http.StatusNotFound, "live course not found") return } writeError(w, http.StatusInternalServerError, "failed to load live course") return } var payload liveCourseCohortUpsertRequest if err := decodeJSON(r, &payload); err != nil { writeError(w, http.StatusBadRequest, "invalid payload") return } doc, err := prepareLiveCourseCohortPayload(payload.Doc(), false) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } doc["liveCourseId"] = courseID doc, err = a.create("live_course_cohorts", doc) if err != nil { writeError(w, http.StatusInternalServerError, "failed to create resource") return } writeJSON(w, http.StatusCreated, doc) } func (a *app) getLiveCourseCohort(w http.ResponseWriter, r *http.Request) { courseID := strings.TrimSpace(chi.URLParam(r, "id")) cohortID := strings.TrimSpace(chi.URLParam(r, "cohortId")) doc, err := a.getByID("live_course_cohorts", cohortID) if err != nil { if errors.Is(err, ErrNotFound) { writeError(w, http.StatusNotFound, "resource not found") return } writeError(w, http.StatusInternalServerError, "failed to load resource") return } if liveCourseID, _ := asString(doc["liveCourseId"]); liveCourseID != courseID { writeError(w, http.StatusNotFound, "resource not found") return } writeJSON(w, http.StatusOK, doc) } func (a *app) patchLiveCourseCohort(w http.ResponseWriter, r *http.Request) { courseID := strings.TrimSpace(chi.URLParam(r, "id")) cohortID := strings.TrimSpace(chi.URLParam(r, "cohortId")) existing, err := a.getByID("live_course_cohorts", cohortID) if err != nil { if errors.Is(err, ErrNotFound) { writeError(w, http.StatusNotFound, "resource not found") return } writeError(w, http.StatusInternalServerError, "failed to load resource") return } if liveCourseID, _ := asString(existing["liveCourseId"]); liveCourseID != courseID { writeError(w, http.StatusNotFound, "resource not found") return } var patch liveCourseCohortUpsertRequest if err := decodeJSON(r, &patch); err != nil { writeError(w, http.StatusBadRequest, "invalid patch payload") return } doc, err := prepareLiveCourseCohortPayload(patch.Doc(), true) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } delete(doc, "liveCourseId") doc, err = a.patch("live_course_cohorts", cohortID, doc) if err != nil { if errors.Is(err, ErrNotFound) { writeError(w, http.StatusNotFound, "resource not found") return } writeError(w, http.StatusInternalServerError, "failed to update resource") return } writeJSON(w, http.StatusOK, doc) } func (a *app) deleteLiveCourseCohort(w http.ResponseWriter, r *http.Request) { courseID := strings.TrimSpace(chi.URLParam(r, "id")) cohortID := strings.TrimSpace(chi.URLParam(r, "cohortId")) existing, err := a.getByID("live_course_cohorts", cohortID) if err != nil { if errors.Is(err, ErrNotFound) { writeError(w, http.StatusNotFound, "resource not found") return } writeError(w, http.StatusInternalServerError, "failed to load resource") return } if liveCourseID, _ := asString(existing["liveCourseId"]); liveCourseID != courseID { writeError(w, http.StatusNotFound, "resource not found") return } if err := a.deleteByID("live_course_cohorts", cohortID); err != nil { if errors.Is(err, ErrNotFound) { writeError(w, http.StatusNotFound, "resource not found") return } writeError(w, http.StatusInternalServerError, "failed to delete resource") return } w.WriteHeader(http.StatusNoContent) } func (a *app) listLiveCourseSessions(w http.ResponseWriter, r *http.Request) { courseID := strings.TrimSpace(chi.URLParam(r, "id")) cohortID := strings.TrimSpace(chi.URLParam(r, "cohortId")) docs, err := a.list("live_course_sessions", Doc{"liveCourseId": courseID, "cohortId": cohortID}, Sort{{Key: "startsAt", Value: 1}, {Key: "updatedAt", Value: -1}}) if err != nil { writeError(w, http.StatusInternalServerError, "failed to list sessions") return } writeJSON(w, http.StatusOK, docs) } func (a *app) createLiveCourseSession(w http.ResponseWriter, r *http.Request) { courseID := strings.TrimSpace(chi.URLParam(r, "id")) cohortID := strings.TrimSpace(chi.URLParam(r, "cohortId")) cohort, err := a.getByID("live_course_cohorts", cohortID) if err != nil { if errors.Is(err, ErrNotFound) { writeError(w, http.StatusNotFound, "cohort not found") return } writeError(w, http.StatusInternalServerError, "failed to load cohort") return } if liveCourseID, _ := asString(cohort["liveCourseId"]); liveCourseID != courseID { writeError(w, http.StatusNotFound, "cohort not found") return } var payload liveCourseSessionUpsertRequest if err := decodeJSON(r, &payload); err != nil { writeError(w, http.StatusBadRequest, "invalid payload") return } doc, err := prepareLiveCourseSessionPayload(payload.Doc(), false) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } doc["liveCourseId"] = courseID doc["cohortId"] = cohortID doc, err = a.create("live_course_sessions", doc) if err != nil { writeError(w, http.StatusInternalServerError, "failed to create resource") return } writeJSON(w, http.StatusCreated, doc) } func (a *app) getLiveCourseSession(w http.ResponseWriter, r *http.Request) { courseID := strings.TrimSpace(chi.URLParam(r, "id")) cohortID := strings.TrimSpace(chi.URLParam(r, "cohortId")) sessionID := strings.TrimSpace(chi.URLParam(r, "sessionId")) doc, err := a.getByID("live_course_sessions", sessionID) if err != nil { if errors.Is(err, ErrNotFound) { writeError(w, http.StatusNotFound, "resource not found") return } writeError(w, http.StatusInternalServerError, "failed to load resource") return } if liveCourseID, _ := asString(doc["liveCourseId"]); liveCourseID != courseID { writeError(w, http.StatusNotFound, "resource not found") return } if sessionCohortID, _ := asString(doc["cohortId"]); sessionCohortID != cohortID { writeError(w, http.StatusNotFound, "resource not found") return } writeJSON(w, http.StatusOK, doc) } func (a *app) patchLiveCourseSession(w http.ResponseWriter, r *http.Request) { courseID := strings.TrimSpace(chi.URLParam(r, "id")) cohortID := strings.TrimSpace(chi.URLParam(r, "cohortId")) sessionID := strings.TrimSpace(chi.URLParam(r, "sessionId")) existing, err := a.getByID("live_course_sessions", sessionID) if err != nil { if errors.Is(err, ErrNotFound) { writeError(w, http.StatusNotFound, "resource not found") return } writeError(w, http.StatusInternalServerError, "failed to load resource") return } if liveCourseID, _ := asString(existing["liveCourseId"]); liveCourseID != courseID { writeError(w, http.StatusNotFound, "resource not found") return } if sessionCohortID, _ := asString(existing["cohortId"]); sessionCohortID != cohortID { writeError(w, http.StatusNotFound, "resource not found") return } var patch liveCourseSessionUpsertRequest if err := decodeJSON(r, &patch); err != nil { writeError(w, http.StatusBadRequest, "invalid patch payload") return } doc, err := prepareLiveCourseSessionPayload(patch.Doc(), true) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } delete(doc, "liveCourseId") delete(doc, "cohortId") doc, err = a.patch("live_course_sessions", sessionID, doc) if err != nil { if errors.Is(err, ErrNotFound) { writeError(w, http.StatusNotFound, "resource not found") return } writeError(w, http.StatusInternalServerError, "failed to update resource") return } writeJSON(w, http.StatusOK, doc) } func (a *app) deleteLiveCourseSession(w http.ResponseWriter, r *http.Request) { courseID := strings.TrimSpace(chi.URLParam(r, "id")) cohortID := strings.TrimSpace(chi.URLParam(r, "cohortId")) sessionID := strings.TrimSpace(chi.URLParam(r, "sessionId")) existing, err := a.getByID("live_course_sessions", sessionID) if err != nil { if errors.Is(err, ErrNotFound) { writeError(w, http.StatusNotFound, "resource not found") return } writeError(w, http.StatusInternalServerError, "failed to load resource") return } if liveCourseID, _ := asString(existing["liveCourseId"]); liveCourseID != courseID { writeError(w, http.StatusNotFound, "resource not found") return } if sessionCohortID, _ := asString(existing["cohortId"]); sessionCohortID != cohortID { writeError(w, http.StatusNotFound, "resource not found") return } if err := a.deleteByID("live_course_sessions", sessionID); err != nil { if errors.Is(err, ErrNotFound) { writeError(w, http.StatusNotFound, "resource not found") return } writeError(w, http.StatusInternalServerError, "failed to delete resource") return } w.WriteHeader(http.StatusNoContent) } func (a *app) listLiveCourseEnrollments(w http.ResponseWriter, r *http.Request) { courseID := strings.TrimSpace(chi.URLParam(r, "id")) cohortID := strings.TrimSpace(chi.URLParam(r, "cohortId")) if _, err := a.getScopedLiveCourseCohort(courseID, cohortID); err != nil { a.writeScopedLookupError(w, err, "cohort") return } docs, err := a.list("live_course_enrollments", Doc{"liveCourseId": courseID, "cohortId": cohortID}, Sort{{Key: "updatedAt", Value: -1}}) if err != nil { writeError(w, http.StatusInternalServerError, "failed to list enrollments") return } writeJSON(w, http.StatusOK, docs) } func (a *app) createLiveCourseEnrollment(w http.ResponseWriter, r *http.Request) { courseID := strings.TrimSpace(chi.URLParam(r, "id")) cohortID := strings.TrimSpace(chi.URLParam(r, "cohortId")) if _, err := a.getScopedLiveCourseCohort(courseID, cohortID); err != nil { a.writeScopedLookupError(w, err, "cohort") return } var payload liveCourseEnrollmentUpsertRequest if err := decodeJSON(r, &payload); err != nil { writeError(w, http.StatusBadRequest, "invalid payload") return } doc, err := prepareLiveCourseEnrollmentPayload(payload.Doc(), false) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } doc["liveCourseId"] = courseID doc["cohortId"] = cohortID doc, err = a.create("live_course_enrollments", doc) if err != nil { writeError(w, http.StatusInternalServerError, "failed to create resource") return } writeJSON(w, http.StatusCreated, doc) } func (a *app) patchLiveCourseEnrollment(w http.ResponseWriter, r *http.Request) { courseID := strings.TrimSpace(chi.URLParam(r, "id")) cohortID := strings.TrimSpace(chi.URLParam(r, "cohortId")) enrollmentID := strings.TrimSpace(chi.URLParam(r, "enrollmentId")) if _, err := a.getScopedLiveCourseCohort(courseID, cohortID); err != nil { a.writeScopedLookupError(w, err, "cohort") return } existing, err := a.getByID("live_course_enrollments", enrollmentID) if err != nil { if errors.Is(err, ErrNotFound) { writeError(w, http.StatusNotFound, "resource not found") return } writeError(w, http.StatusInternalServerError, "failed to load resource") return } if liveCourseID, _ := asString(existing["liveCourseId"]); liveCourseID != courseID { writeError(w, http.StatusNotFound, "resource not found") return } if enrollmentCohortID, _ := asString(existing["cohortId"]); enrollmentCohortID != cohortID { writeError(w, http.StatusNotFound, "resource not found") return } var patch liveCourseEnrollmentUpsertRequest if err := decodeJSON(r, &patch); err != nil { writeError(w, http.StatusBadRequest, "invalid patch payload") return } doc, err := prepareLiveCourseEnrollmentPayload(patch.Doc(), true) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } delete(doc, "liveCourseId") delete(doc, "cohortId") doc, err = a.patch("live_course_enrollments", enrollmentID, doc) if err != nil { if errors.Is(err, ErrNotFound) { writeError(w, http.StatusNotFound, "resource not found") return } writeError(w, http.StatusInternalServerError, "failed to update resource") return } writeJSON(w, http.StatusOK, doc) } func (a *app) listLiveCourseAttendance(w http.ResponseWriter, r *http.Request) { courseID := strings.TrimSpace(chi.URLParam(r, "id")) cohortID := strings.TrimSpace(chi.URLParam(r, "cohortId")) sessionID := strings.TrimSpace(chi.URLParam(r, "sessionId")) if _, err := a.getScopedLiveCourseSession(courseID, cohortID, sessionID); err != nil { a.writeScopedLookupError(w, err, "session") return } docs, err := a.list("live_course_attendance", Doc{"liveCourseId": courseID, "cohortId": cohortID, "sessionId": sessionID}, Sort{{Key: "updatedAt", Value: -1}}) if err != nil { writeError(w, http.StatusInternalServerError, "failed to list attendance") return } writeJSON(w, http.StatusOK, docs) } func (a *app) createLiveCourseAttendance(w http.ResponseWriter, r *http.Request) { courseID := strings.TrimSpace(chi.URLParam(r, "id")) cohortID := strings.TrimSpace(chi.URLParam(r, "cohortId")) sessionID := strings.TrimSpace(chi.URLParam(r, "sessionId")) if _, err := a.getScopedLiveCourseSession(courseID, cohortID, sessionID); err != nil { a.writeScopedLookupError(w, err, "session") return } var payload liveCourseAttendanceUpsertRequest if err := decodeJSON(r, &payload); err != nil { writeError(w, http.StatusBadRequest, "invalid payload") return } doc, err := prepareLiveCourseAttendancePayload(payload.Doc(), false) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } claims, _ := r.Context().Value(userClaimsKey).(*accessClaims) if claims != nil && strings.TrimSpace(claims.Sub) != "" { doc["markedBy"] = strings.TrimSpace(claims.Sub) } doc["liveCourseId"] = courseID doc["cohortId"] = cohortID doc["sessionId"] = sessionID doc, err = a.create("live_course_attendance", doc) if err != nil { writeError(w, http.StatusInternalServerError, "failed to create resource") return } writeJSON(w, http.StatusCreated, doc) } func (a *app) patchLiveCourseAttendance(w http.ResponseWriter, r *http.Request) { courseID := strings.TrimSpace(chi.URLParam(r, "id")) cohortID := strings.TrimSpace(chi.URLParam(r, "cohortId")) sessionID := strings.TrimSpace(chi.URLParam(r, "sessionId")) attendanceID := strings.TrimSpace(chi.URLParam(r, "attendanceId")) if _, err := a.getScopedLiveCourseSession(courseID, cohortID, sessionID); err != nil { a.writeScopedLookupError(w, err, "session") return } existing, err := a.getByID("live_course_attendance", attendanceID) if err != nil { if errors.Is(err, ErrNotFound) { writeError(w, http.StatusNotFound, "resource not found") return } writeError(w, http.StatusInternalServerError, "failed to load resource") return } if liveCourseID, _ := asString(existing["liveCourseId"]); liveCourseID != courseID { writeError(w, http.StatusNotFound, "resource not found") return } if attendanceCohortID, _ := asString(existing["cohortId"]); attendanceCohortID != cohortID { writeError(w, http.StatusNotFound, "resource not found") return } if attendanceSessionID, _ := asString(existing["sessionId"]); attendanceSessionID != sessionID { writeError(w, http.StatusNotFound, "resource not found") return } var patch liveCourseAttendanceUpsertRequest if err := decodeJSON(r, &patch); err != nil { writeError(w, http.StatusBadRequest, "invalid patch payload") return } doc, err := prepareLiveCourseAttendancePayload(patch.Doc(), true) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } delete(doc, "liveCourseId") delete(doc, "cohortId") delete(doc, "sessionId") claims, _ := r.Context().Value(userClaimsKey).(*accessClaims) if claims != nil && strings.TrimSpace(claims.Sub) != "" { doc["markedBy"] = strings.TrimSpace(claims.Sub) } doc, err = a.patch("live_course_attendance", attendanceID, doc) if err != nil { if errors.Is(err, ErrNotFound) { writeError(w, http.StatusNotFound, "resource not found") return } writeError(w, http.StatusInternalServerError, "failed to update resource") return } writeJSON(w, http.StatusOK, doc) } func (a *app) getScopedLiveCourseCohort(courseID, cohortID string) (Doc, error) { cohort, err := a.getByID("live_course_cohorts", cohortID) if err != nil { return nil, err } if liveCourseID, _ := asString(cohort["liveCourseId"]); liveCourseID != courseID { return nil, ErrNotFound } return cohort, nil } func (a *app) getScopedLiveCourseSession(courseID, cohortID, sessionID string) (Doc, error) { session, err := a.getByID("live_course_sessions", sessionID) if err != nil { return nil, err } if liveCourseID, _ := asString(session["liveCourseId"]); liveCourseID != courseID { return nil, ErrNotFound } if sessionCohortID, _ := asString(session["cohortId"]); sessionCohortID != cohortID { return nil, ErrNotFound } return session, nil } func (a *app) writeScopedLookupError(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) } func (a *app) loadLearnerSessionContext(userID, courseID, sessionID string) (Doc, Doc, Doc, Doc, error) { course, err := a.getByID("live_courses", courseID) if err != nil { return nil, nil, nil, nil, err } session, err := a.getByID("live_course_sessions", sessionID) if err != nil { return nil, nil, nil, nil, err } sessionCourseID, _ := courseIDFromDocument(session) if sessionCourseID != courseID { return nil, nil, nil, nil, ErrNotFound } cohortID, _ := asString(session["cohortId"]) cohort, err := a.getByID("live_course_cohorts", cohortID) if err != nil { return nil, nil, nil, nil, err } enrollments, err := a.list("live_course_enrollments", Doc{ "userId": userID, "cohortId": cohortID, "$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, nil, err } enrollment := firstDocument(enrollments) if enrollment == nil && !isPublicLiveCourse(course, time.Now().UTC()) { return nil, nil, nil, nil, ErrNotFound } return course, session, cohort, enrollment, nil } func (a *app) writeLearnerSessionContextError(w http.ResponseWriter, err error) { if errors.Is(err, ErrNotFound) { writeError(w, http.StatusNotFound, "session not found") return } writeError(w, http.StatusInternalServerError, "failed to load learner session") } func (a *app) getPublicLiveCourseBySlugOrNotFound(slug string) (Doc, error) { if slug == "" { return nil, ErrNotFound } docs, err := a.list("live_courses", Doc{"slug": slug}, Sort{{Key: "updatedAt", Value: -1}}) if err != nil { return nil, err } if len(docs) == 0 { return nil, ErrNotFound } if !isPublicLiveCourse(docs[0], time.Now().UTC()) { return nil, ErrNotFound } return docs[0], nil } func isPublicLiveCourse(doc Doc, now time.Time) bool { status, _ := asString(doc["status"]) normalizedStatus := strings.TrimSpace(strings.ToLower(status)) if normalizedStatus != liveCourseStatusPublished && normalizedStatus != liveCourseStatusScheduled { return false } visibility, _ := asString(doc["visibility"]) if strings.TrimSpace(strings.ToLower(visibility)) != "public" { return false } publishedAt, hasPublishedAt := toTime(doc["publishedAt"]) if hasPublishedAt && publishedAt.After(now) { return false } if normalizedStatus == liveCourseStatusScheduled && !hasPublishedAt { return false } slug, _ := asString(doc["slug"]) title, _ := asString(doc["title"]) return strings.TrimSpace(slug) != "" && strings.TrimSpace(title) != "" } func isOpenPublicLiveCourseCohort(doc Doc, _ time.Time) bool { status, _ := asString(doc["status"]) if strings.TrimSpace(strings.ToLower(status)) != "open" { return false } if organisationID, _ := asString(doc["organisationId"]); strings.TrimSpace(organisationID) != "" { return false } return true } func (a *app) learnerSubjectFromContext(r *http.Request) (string, bool) { claims, _ := r.Context().Value(userClaimsKey).(*accessClaims) if claims == nil { return "", false } candidates := make([]string, 0, 3) for _, candidate := range []string{claims.Sub, claims.PreferredUsername, claims.Email} { if value, ok := asString(candidate); ok { candidates = append(candidates, value) } } if len(candidates) == 0 { return "", false } tenantID := "" if tenant, ok := r.Context().Value(tenantContextKey).(tenantContext); ok { tenantID = strings.TrimSpace(tenant.ID) } if a != nil && a.db != nil { for _, identity := range candidates { filter := Doc{ "$or": List{ Doc{"id": identity}, Doc{"keycloakUsername": identity}, Doc{"email": identity}, }, } if tenantID != "" { filter["tenantId"] = tenantID } users, err := a.list("users", filter, Sort{{Key: "updatedAt", Value: -1}, {Key: "createdAt", Value: -1}}) if err != nil || len(users) == 0 { continue } if userID, ok := asString(users[0]["id"]); ok { return userID, true } } } return candidates[0], true } func evaluateLearnerAccess(course, cohort, enrollment Doc, entitlementAllowed bool, entitlementReason, entitlementMessage string) Doc { if enrollment == nil { return learnerAccessResult(false, "missing_enrollment", "You are not enrolled in this live course.") } status, _ := asString(enrollment["status"]) status = strings.ToLower(strings.TrimSpace(status)) if status != "enrolled" && status != "completed" { return learnerAccessResult(false, "missing_enrollment", "Your enrollment is not active for this cohort.") } if !entitlementAllowed { if strings.TrimSpace(entitlementReason) == "" { entitlementReason = "missing_entitlement" } if strings.TrimSpace(entitlementMessage) == "" { entitlementMessage = "Enrollment found, but your entitlement is missing or invalid for this cohort." } return learnerAccessResult(false, entitlementReason, entitlementMessage) } return learnerAccessResult(true, "", "") } func evaluateLearnerSessionAccess(course, cohort, session, enrollment Doc, entitlementAllowed bool, entitlementReason, entitlementMessage string) Doc { base := evaluateLearnerAccess(course, cohort, enrollment, entitlementAllowed, entitlementReason, entitlementMessage) allowed, _ := base["allowed"].(bool) if !allowed { return base } meetingURL, _ := asString(session["meetingUrl"]) if meetingURL == "" { return learnerAccessResult(false, "session_unavailable", "This session does not have an active join link yet.") } status, _ := asString(session["status"]) normalized := strings.ToLower(strings.TrimSpace(status)) if normalized != "scheduled" && normalized != "live" { return learnerAccessResult(false, "session_unavailable", "This session is not currently joinable.") } return learnerAccessResult(true, "", "") } func learnerAccessResult(allowed bool, reason, message string) Doc { result := Doc{"allowed": allowed} if reason != "" { result["reason"] = reason } if message != "" { result["message"] = message } return result } func resolveEntitlementKey(course, cohort Doc) string { if cohort != nil { if entitlementKey, ok := asString(cohort["entitlementKey"]); ok { return entitlementKey } } if course != nil { if entitlementKey, ok := asString(course["entitlementKey"]); ok { return entitlementKey } } return "" } func mapKeys(values map[string]struct{}) []string { out := make([]string, 0, len(values)) for key := range values { if strings.TrimSpace(key) != "" { out = append(out, key) } } return out } func indexByID(docs []Doc) map[string]Doc { out := make(map[string]Doc, len(docs)) for _, doc := range docs { if id, ok := asString(doc["id"]); ok { out[id] = doc } } return out } func indexEnrollmentsByCohort(docs []Doc) map[string]Doc { out := make(map[string]Doc, len(docs)) for _, doc := range docs { cohortID, ok := asString(doc["cohortId"]) if !ok { continue } if _, exists := out[cohortID]; !exists { out[cohortID] = doc } } return out } func firstDocument(docs []Doc) Doc { if len(docs) == 0 { return nil } return docs[0] } func courseIDFromDocument(doc Doc) (string, bool) { if doc == nil { return "", false } if value, ok := asString(doc["liveCourseId"]); ok { return value, true } if value, ok := asString(doc["courseId"]); ok { return value, true } return "", false } func compositeScopeKey(courseID, cohortID string) string { return strings.TrimSpace(courseID) + "::" + strings.TrimSpace(cohortID) } func toTime(value any) (time.Time, bool) { switch v := value.(type) { case int64: return time.UnixMilli(v).UTC(), true case int32: return time.UnixMilli(int64(v)).UTC(), true case int: return time.UnixMilli(int64(v)).UTC(), true case float64: if v < 0 { return time.Time{}, false } return time.UnixMilli(int64(v)).UTC(), true case string: parsed, err := time.Parse(time.RFC3339, strings.TrimSpace(v)) if err != nil { return time.Time{}, false } return parsed.UTC(), true default: return time.Time{}, false } }