1395 lines
42 KiB
Go

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