1395 lines
42 KiB
Go
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
|
|
}
|
|
}
|