sync: learning operations backend 1/2

This commit is contained in:
Admin 2026-09-27 23:14:16 +00:00
parent 2fdd51f921
commit 7c7aec1984
9 changed files with 1475 additions and 10 deletions

View File

@ -0,0 +1,469 @@
package main
import (
"errors"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
)
func prepareCommunityPostPayload(communityID, channelID, authorID string, payload Doc, partial bool) (Doc, error) {
if payload == nil {
payload = Doc{}
}
if !partial {
payload["communityId"] = strings.TrimSpace(communityID)
payload["channelId"] = strings.TrimSpace(channelID)
payload["authorId"] = strings.TrimSpace(authorID)
}
body, hasBody, err := getOptionalString(payload, "body")
if err != nil {
return nil, errors.New("body must be a string")
}
if !partial && (!hasBody || body == "") {
return nil, errors.New("body is required")
}
if hasBody {
if len(body) > 10000 {
return nil, errors.New("body must be 10000 characters or fewer")
}
payload["body"] = body
if partial {
payload["editedAt"] = time.Now().UnixMilli()
}
}
status, hasStatus, err := getOptionalString(payload, "status")
if err != nil {
return nil, errors.New("status must be a string")
}
if !partial && !hasStatus {
status = "active"
hasStatus = true
}
if hasStatus {
status = strings.ToLower(strings.TrimSpace(status))
switch status {
case "active", "hidden", "deleted":
payload["status"] = status
default:
return nil, errors.New("status must be one of: active, hidden, deleted")
}
}
if pinned, has, err := getOptionalBool(payload, "pinned"); err != nil {
return nil, errors.New("pinned must be a boolean")
} else if has {
payload["pinned"] = pinned
} else if !partial {
payload["pinned"] = false
}
return payload, nil
}
func prepareCommunityReplyPayload(postID, authorID string, payload Doc, partial bool) (Doc, error) {
if payload == nil {
payload = Doc{}
}
if !partial {
payload["postId"] = strings.TrimSpace(postID)
payload["authorId"] = strings.TrimSpace(authorID)
}
body, hasBody, err := getOptionalString(payload, "body")
if err != nil {
return nil, errors.New("body must be a string")
}
if !partial && (!hasBody || body == "") {
return nil, errors.New("body is required")
}
if hasBody {
if len(body) > 10000 {
return nil, errors.New("body must be 10000 characters or fewer")
}
payload["body"] = body
if partial {
payload["editedAt"] = time.Now().UnixMilli()
}
}
status, hasStatus, err := getOptionalString(payload, "status")
if err != nil {
return nil, errors.New("status must be a string")
}
if !partial && !hasStatus {
status = "active"
hasStatus = true
}
if hasStatus {
status = strings.ToLower(strings.TrimSpace(status))
switch status {
case "active", "hidden", "deleted":
payload["status"] = status
default:
return nil, errors.New("status must be one of: active, hidden, deleted")
}
}
return payload, nil
}
func (a *app) listLearnerCommunityPosts(w http.ResponseWriter, r *http.Request) {
community, channel, claims, ok := a.loadCommunityConversationContext(w, r)
if !ok {
return
}
_ = community
posts, err := a.list("community_posts", Doc{"communityId": chi.URLParam(r, "id"), "channelId": stringValue(channel["id"]), "status": "active"}, Sort{{Key: "pinned", Value: -1}, {Key: "createdAt", Value: -1}})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list community posts")
return
}
writeJSON(w, http.StatusOK, a.decorateCommunityPosts(posts, false, claims))
}
func (a *app) createLearnerCommunityPost(w http.ResponseWriter, r *http.Request) {
_, channel, claims, ok := a.loadCommunityConversationContext(w, r)
if !ok {
return
}
communityID := strings.TrimSpace(chi.URLParam(r, "id"))
if !a.communityPostingAllowed(claims, communityID) {
writeError(w, http.StatusForbidden, "join the community before posting")
return
}
var body map[string]any
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid post payload")
return
}
doc, err := prepareCommunityPostPayload(communityID, stringValue(channel["id"]), claims.Sub, Doc(body), false)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if tenantID := tenantIDForLearningRequest(r); tenantID != "" {
doc["tenantId"] = tenantID
}
doc, err = a.create("community_posts", doc)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create community post")
return
}
writeJSON(w, http.StatusCreated, a.decorateCommunityPost(doc, false, claims))
}
func (a *app) listLearnerCommunityReplies(w http.ResponseWriter, r *http.Request) {
communityID, postID := strings.TrimSpace(chi.URLParam(r, "id")), strings.TrimSpace(chi.URLParam(r, "postId"))
community, err := a.getByID("communities", communityID)
if err != nil {
a.writeLearningNotFound(w, err, "community")
return
}
claims, _ := r.Context().Value(userClaimsKey).(*accessClaims)
allowed, _, err := a.canAccessCommunityForClaims(claims, community)
if err != nil || !allowed {
writeError(w, http.StatusForbidden, "community access denied")
return
}
if _, err := a.getCommunityPostByID(communityID, postID); err != nil {
a.writeLearningNotFound(w, err, "community post")
return
}
replies, err := a.list("community_replies", Doc{"postId": postID, "status": "active"}, Sort{{Key: "createdAt", Value: 1}})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list replies")
return
}
writeJSON(w, http.StatusOK, a.decorateCommunityReplies(replies, false))
}
func (a *app) createLearnerCommunityReply(w http.ResponseWriter, r *http.Request) {
communityID, postID := strings.TrimSpace(chi.URLParam(r, "id")), strings.TrimSpace(chi.URLParam(r, "postId"))
community, err := a.getByID("communities", communityID)
if err != nil {
a.writeLearningNotFound(w, err, "community")
return
}
claims, _ := r.Context().Value(userClaimsKey).(*accessClaims)
allowed, _, err := a.canAccessCommunityForClaims(claims, community)
if err != nil || !allowed {
writeError(w, http.StatusForbidden, "community access denied")
return
}
if !a.communityPostingAllowed(claims, communityID) {
writeError(w, http.StatusForbidden, "join the community before replying")
return
}
if _, err := a.getCommunityPostByID(communityID, postID); err != nil {
a.writeLearningNotFound(w, err, "community post")
return
}
var body map[string]any
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid reply payload")
return
}
doc, err := prepareCommunityReplyPayload(postID, claims.Sub, Doc(body), false)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if tenantID := tenantIDForLearningRequest(r); tenantID != "" {
doc["tenantId"] = tenantID
}
doc, err = a.create("community_replies", doc)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create reply")
return
}
writeJSON(w, http.StatusCreated, a.decorateCommunityReply(doc))
}
func (a *app) listAdminCommunityPosts(w http.ResponseWriter, r *http.Request) {
communityID, channelID := strings.TrimSpace(chi.URLParam(r, "id")), strings.TrimSpace(chi.URLParam(r, "channelId"))
if _, err := a.getCommunityChannelByID(communityID, channelID); err != nil {
a.writeLearningNotFound(w, err, "community channel")
return
}
posts, err := a.list("community_posts", Doc{"communityId": communityID, "channelId": channelID}, Sort{{Key: "pinned", Value: -1}, {Key: "createdAt", Value: -1}})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list community posts")
return
}
claims, _ := r.Context().Value(userClaimsKey).(*accessClaims)
writeJSON(w, http.StatusOK, a.decorateCommunityPosts(posts, true, claims))
}
func (a *app) createAdminCommunityPost(w http.ResponseWriter, r *http.Request) {
communityID, channelID := strings.TrimSpace(chi.URLParam(r, "id")), strings.TrimSpace(chi.URLParam(r, "channelId"))
if _, err := a.getCommunityChannelByID(communityID, channelID); err != nil {
a.writeLearningNotFound(w, err, "community channel")
return
}
claims, _ := r.Context().Value(userClaimsKey).(*accessClaims)
var body map[string]any
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid post payload")
return
}
doc, err := prepareCommunityPostPayload(communityID, channelID, resolveClaimsUserID(claims), Doc(body), false)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if tenantID := tenantIDForLearningRequest(r); tenantID != "" {
doc["tenantId"] = tenantID
}
doc, err = a.create("community_posts", doc)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create community post")
return
}
writeJSON(w, http.StatusCreated, doc)
}
func (a *app) patchAdminCommunityPost(w http.ResponseWriter, r *http.Request) {
communityID, postID := strings.TrimSpace(chi.URLParam(r, "id")), strings.TrimSpace(chi.URLParam(r, "postId"))
if _, err := a.getCommunityPostByID(communityID, postID); err != nil {
a.writeLearningNotFound(w, err, "community post")
return
}
var body map[string]any
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid post payload")
return
}
patch, err := prepareCommunityPostPayload("", "", "", Doc(body), true)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
doc, err := a.patch("community_posts", postID, patch)
if err != nil {
a.writeLearningNotFound(w, err, "community post")
return
}
writeJSON(w, http.StatusOK, doc)
}
func (a *app) listAdminCommunityReplies(w http.ResponseWriter, r *http.Request) {
communityID, postID := strings.TrimSpace(chi.URLParam(r, "id")), strings.TrimSpace(chi.URLParam(r, "postId"))
if _, err := a.getCommunityPostByID(communityID, postID); err != nil {
a.writeLearningNotFound(w, err, "community post")
return
}
replies, err := a.list("community_replies", Doc{"postId": postID}, Sort{{Key: "createdAt", Value: 1}})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list replies")
return
}
writeJSON(w, http.StatusOK, a.decorateCommunityReplies(replies, true))
}
func (a *app) createAdminCommunityReply(w http.ResponseWriter, r *http.Request) {
communityID, postID := strings.TrimSpace(chi.URLParam(r, "id")), strings.TrimSpace(chi.URLParam(r, "postId"))
if _, err := a.getCommunityPostByID(communityID, postID); err != nil {
a.writeLearningNotFound(w, err, "community post")
return
}
claims, _ := r.Context().Value(userClaimsKey).(*accessClaims)
var body map[string]any
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid reply payload")
return
}
doc, err := prepareCommunityReplyPayload(postID, resolveClaimsUserID(claims), Doc(body), false)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if tenantID := tenantIDForLearningRequest(r); tenantID != "" {
doc["tenantId"] = tenantID
}
doc, err = a.create("community_replies", doc)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create reply")
return
}
writeJSON(w, http.StatusCreated, doc)
}
func (a *app) patchAdminCommunityReply(w http.ResponseWriter, r *http.Request) {
communityID, replyID := strings.TrimSpace(chi.URLParam(r, "id")), strings.TrimSpace(chi.URLParam(r, "replyId"))
reply, err := a.getByID("community_replies", replyID)
if err != nil {
a.writeLearningNotFound(w, err, "community reply")
return
}
if _, err := a.getCommunityPostByID(communityID, stringValue(reply["postId"])); err != nil {
writeError(w, http.StatusNotFound, "community reply not found")
return
}
var body map[string]any
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid reply payload")
return
}
patch, err := prepareCommunityReplyPayload("", "", Doc(body), true)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
doc, err := a.patch("community_replies", replyID, patch)
if err != nil {
a.writeLearningNotFound(w, err, "community reply")
return
}
writeJSON(w, http.StatusOK, doc)
}
func (a *app) loadCommunityConversationContext(w http.ResponseWriter, r *http.Request) (Doc, Doc, *accessClaims, bool) {
communityID, channelID := strings.TrimSpace(chi.URLParam(r, "id")), strings.TrimSpace(chi.URLParam(r, "channelId"))
claims, _ := r.Context().Value(userClaimsKey).(*accessClaims)
community, err := a.getByID("communities", communityID)
if err != nil {
a.writeLearningNotFound(w, err, "community")
return nil, nil, claims, false
}
allowed, _, err := a.canAccessCommunityForClaims(claims, community)
if err != nil || !allowed {
writeError(w, http.StatusForbidden, "community access denied")
return nil, nil, claims, false
}
channel, err := a.getCommunityChannelByID(communityID, channelID)
if err != nil {
a.writeLearningNotFound(w, err, "community channel")
return nil, nil, claims, false
}
if private, _ := channel["isPrivate"].(bool); private {
membership, _ := a.findCommunityMembership(communityID, claims.Sub)
if membership == nil && !hasRole(claims, "admin") && !hasRole(claims, "instructor") {
writeError(w, http.StatusForbidden, "private channel requires membership")
return nil, nil, claims, false
}
}
return community, channel, claims, true
}
func (a *app) communityPostingAllowed(claims *accessClaims, communityID string) bool {
if claims == nil {
return false
}
if hasRole(claims, "admin") || hasRole(claims, "instructor") {
return true
}
membership, _ := a.findCommunityMembership(communityID, claims.Sub)
return membership != nil && strings.EqualFold(stringValue(membership["status"]), "active")
}
func (a *app) getCommunityPostByID(communityID, postID string) (Doc, error) {
post, err := a.getByID("community_posts", postID)
if err != nil {
return nil, err
}
if strings.TrimSpace(stringValue(post["communityId"])) != strings.TrimSpace(communityID) {
return nil, ErrNotFound
}
return post, nil
}
func (a *app) decorateCommunityPosts(posts []Doc, includeModerated bool, claims *accessClaims) []Doc {
out := make([]Doc, 0, len(posts))
for _, post := range posts {
if !includeModerated && !strings.EqualFold(stringValue(post["status"]), "active") {
continue
}
out = append(out, a.decorateCommunityPost(post, includeModerated, claims))
}
return out
}
func (a *app) decorateCommunityPost(post Doc, includeModerated bool, _ *accessClaims) Doc {
copy := Doc{}
for k, v := range post {
copy[k] = v
}
copy["author"] = a.communityAuthorSummary(stringValue(post["authorId"]))
replies, _ := a.list("community_replies", Doc{"postId": stringValue(post["id"])}, Sort{{Key: "createdAt", Value: 1}})
copy["replies"] = a.decorateCommunityReplies(replies, includeModerated)
return copy
}
func (a *app) decorateCommunityReplies(replies []Doc, includeModerated bool) []Doc {
out := []Doc{}
for _, reply := range replies {
if !includeModerated && !strings.EqualFold(stringValue(reply["status"]), "active") {
continue
}
out = append(out, a.decorateCommunityReply(reply))
}
return out
}
func (a *app) decorateCommunityReply(reply Doc) Doc {
copy := Doc{}
for k, v := range reply {
copy[k] = v
}
copy["author"] = a.communityAuthorSummary(stringValue(reply["authorId"]))
return copy
}
func (a *app) communityAuthorSummary(userID string) Doc {
if strings.TrimSpace(userID) == "" {
return Doc{"id": "", "name": "Former member"}
}
user, err := a.getByID("users", userID)
if err != nil {
return Doc{"id": userID, "name": "Member"}
}
name := stringValue(user["displayName"])
if name == "" {
name = stringValue(user["name"])
}
if name == "" {
name = stringValue(user["username"])
}
if name == "" {
name = "Member"
}
return Doc{"id": userID, "name": name}
}

View File

@ -0,0 +1,626 @@
package main
import (
"errors"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
)
func tenantIDForLearningRequest(r *http.Request) string {
if r == nil {
return ""
}
tenant, _ := r.Context().Value(tenantContextKey).(tenantContext)
return strings.TrimSpace(tenant.ID)
}
func prepareCourseModulePayload(courseID string, payload Doc, partial bool) (Doc, error) {
if payload == nil {
payload = Doc{}
}
payload["courseId"] = strings.TrimSpace(courseID)
title, hasTitle, err := getOptionalString(payload, "title")
if err != nil {
return nil, errors.New("title must be a string")
}
if !partial && (!hasTitle || title == "") {
return nil, errors.New("title is required")
}
if hasTitle {
payload["title"] = title
}
if description, has, err := getOptionalString(payload, "description"); err != nil {
return nil, errors.New("description must be a string")
} else if has {
payload["description"] = description
}
if position, has, err := getOptionalInteger(payload, "position"); err != nil {
return nil, errors.New("position must be an integer")
} else if has {
if position < 0 {
return nil, errors.New("position must be greater than or equal to 0")
}
payload["position"] = position
} else if !partial {
payload["position"] = 0
}
status, hasStatus, err := getOptionalString(payload, "status")
if err != nil {
return nil, errors.New("status must be a string")
}
if !partial && !hasStatus {
status = "draft"
hasStatus = true
}
if hasStatus {
status = strings.ToLower(strings.TrimSpace(status))
switch status {
case "draft", "published", "archived":
payload["status"] = status
default:
return nil, errors.New("status must be one of: draft, published, archived")
}
}
return payload, nil
}
func prepareCourseLessonPayload(courseID, moduleID string, payload Doc, partial bool) (Doc, error) {
if payload == nil {
payload = Doc{}
}
payload["courseId"] = strings.TrimSpace(courseID)
payload["moduleId"] = strings.TrimSpace(moduleID)
title, hasTitle, err := getOptionalString(payload, "title")
if err != nil {
return nil, errors.New("title must be a string")
}
if !partial && (!hasTitle || title == "") {
return nil, errors.New("title is required")
}
if hasTitle {
payload["title"] = title
}
for _, field := range []string{"description", "content", "sourceUrl"} {
value, has, err := getOptionalString(payload, field)
if err != nil {
return nil, errors.New(field + " must be a string")
}
if has {
payload[field] = value
}
}
if position, has, err := getOptionalInteger(payload, "position"); err != nil {
return nil, errors.New("position must be an integer")
} else if has {
if position < 0 {
return nil, errors.New("position must be greater than or equal to 0")
}
payload["position"] = position
} else if !partial {
payload["position"] = 0
}
if duration, has, err := getOptionalInteger(payload, "durationMinutes"); err != nil {
return nil, errors.New("durationMinutes must be an integer")
} else if has {
if duration < 0 {
return nil, errors.New("durationMinutes must be greater than or equal to 0")
}
payload["durationMinutes"] = duration
}
contentType, hasType, err := getOptionalString(payload, "contentType")
if err != nil {
return nil, errors.New("contentType must be a string")
}
if !partial && !hasType {
contentType = "text"
hasType = true
}
if hasType {
contentType = strings.ToLower(strings.TrimSpace(contentType))
switch contentType {
case "text", "video", "link", "document":
payload["contentType"] = contentType
default:
return nil, errors.New("contentType must be one of: text, video, link, document")
}
}
status, hasStatus, err := getOptionalString(payload, "status")
if err != nil {
return nil, errors.New("status must be a string")
}
if !partial && !hasStatus {
status = "draft"
hasStatus = true
}
if hasStatus {
status = strings.ToLower(strings.TrimSpace(status))
switch status {
case "draft", "published", "archived":
payload["status"] = status
default:
return nil, errors.New("status must be one of: draft, published, archived")
}
}
return payload, nil
}
func (a *app) listLiveCourseModules(w http.ResponseWriter, r *http.Request) {
courseID := strings.TrimSpace(chi.URLParam(r, "id"))
if _, err := a.getByID("live_courses", courseID); err != nil {
a.writeLearningNotFound(w, err, "live course")
return
}
docs, err := a.list("course_modules", Doc{"courseId": courseID}, Sort{{Key: "position", Value: 1}, {Key: "createdAt", Value: 1}})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list course modules")
return
}
writeJSON(w, http.StatusOK, docs)
}
func (a *app) createLiveCourseModule(w http.ResponseWriter, r *http.Request) {
courseID := strings.TrimSpace(chi.URLParam(r, "id"))
if _, err := a.getByID("live_courses", courseID); err != nil {
a.writeLearningNotFound(w, err, "live course")
return
}
var body map[string]any
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid module payload")
return
}
doc, err := prepareCourseModulePayload(courseID, Doc(body), false)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if tenantID := tenantIDForLearningRequest(r); tenantID != "" {
doc["tenantId"] = tenantID
}
doc, err = a.create("course_modules", doc)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create course module")
return
}
writeJSON(w, http.StatusCreated, doc)
}
func (a *app) patchLiveCourseModule(w http.ResponseWriter, r *http.Request) {
courseID, moduleID := strings.TrimSpace(chi.URLParam(r, "id")), strings.TrimSpace(chi.URLParam(r, "moduleId"))
module, err := a.getCourseModule(courseID, moduleID)
if err != nil {
a.writeLearningNotFound(w, err, "course module")
return
}
var body map[string]any
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid module payload")
return
}
doc, err := prepareCourseModulePayload(courseID, Doc(body), true)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
_ = module
doc, err = a.patch("course_modules", moduleID, doc)
if err != nil {
a.writeLearningNotFound(w, err, "course module")
return
}
writeJSON(w, http.StatusOK, doc)
}
func (a *app) deleteLiveCourseModule(w http.ResponseWriter, r *http.Request) {
courseID, moduleID := strings.TrimSpace(chi.URLParam(r, "id")), strings.TrimSpace(chi.URLParam(r, "moduleId"))
if _, err := a.getCourseModule(courseID, moduleID); err != nil {
a.writeLearningNotFound(w, err, "course module")
return
}
if err := a.deleteByID("course_modules", moduleID); err != nil {
a.writeLearningNotFound(w, err, "course module")
return
}
w.WriteHeader(http.StatusNoContent)
}
func (a *app) listLiveCourseLessons(w http.ResponseWriter, r *http.Request) {
courseID, moduleID := strings.TrimSpace(chi.URLParam(r, "id")), strings.TrimSpace(chi.URLParam(r, "moduleId"))
if _, err := a.getCourseModule(courseID, moduleID); err != nil {
a.writeLearningNotFound(w, err, "course module")
return
}
docs, err := a.list("course_lessons", Doc{"courseId": courseID, "moduleId": moduleID}, Sort{{Key: "position", Value: 1}, {Key: "createdAt", Value: 1}})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list course lessons")
return
}
writeJSON(w, http.StatusOK, docs)
}
func (a *app) createLiveCourseLesson(w http.ResponseWriter, r *http.Request) {
courseID, moduleID := strings.TrimSpace(chi.URLParam(r, "id")), strings.TrimSpace(chi.URLParam(r, "moduleId"))
if _, err := a.getCourseModule(courseID, moduleID); err != nil {
a.writeLearningNotFound(w, err, "course module")
return
}
var body map[string]any
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid lesson payload")
return
}
doc, err := prepareCourseLessonPayload(courseID, moduleID, Doc(body), false)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if tenantID := tenantIDForLearningRequest(r); tenantID != "" {
doc["tenantId"] = tenantID
}
doc, err = a.create("course_lessons", doc)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create course lesson")
return
}
writeJSON(w, http.StatusCreated, doc)
}
func (a *app) patchLiveCourseLesson(w http.ResponseWriter, r *http.Request) {
courseID, moduleID, lessonID := strings.TrimSpace(chi.URLParam(r, "id")), strings.TrimSpace(chi.URLParam(r, "moduleId")), strings.TrimSpace(chi.URLParam(r, "lessonId"))
if _, err := a.getCourseLesson(courseID, moduleID, lessonID); err != nil {
a.writeLearningNotFound(w, err, "course lesson")
return
}
var body map[string]any
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid lesson payload")
return
}
doc, err := prepareCourseLessonPayload(courseID, moduleID, Doc(body), true)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
doc, err = a.patch("course_lessons", lessonID, doc)
if err != nil {
a.writeLearningNotFound(w, err, "course lesson")
return
}
writeJSON(w, http.StatusOK, doc)
}
func (a *app) deleteLiveCourseLesson(w http.ResponseWriter, r *http.Request) {
courseID, moduleID, lessonID := strings.TrimSpace(chi.URLParam(r, "id")), strings.TrimSpace(chi.URLParam(r, "moduleId")), strings.TrimSpace(chi.URLParam(r, "lessonId"))
if _, err := a.getCourseLesson(courseID, moduleID, lessonID); err != nil {
a.writeLearningNotFound(w, err, "course lesson")
return
}
if err := a.deleteByID("course_lessons", lessonID); err != nil {
a.writeLearningNotFound(w, err, "course lesson")
return
}
w.WriteHeader(http.StatusNoContent)
}
func (a *app) getCourseModule(courseID, moduleID string) (Doc, error) {
doc, err := a.getByID("course_modules", moduleID)
if err != nil {
return nil, err
}
if strings.TrimSpace(stringValue(doc["courseId"])) != strings.TrimSpace(courseID) {
return nil, ErrNotFound
}
return doc, nil
}
func (a *app) getCourseLesson(courseID, moduleID, lessonID string) (Doc, error) {
doc, err := a.getByID("course_lessons", lessonID)
if err != nil {
return nil, err
}
if strings.TrimSpace(stringValue(doc["courseId"])) != strings.TrimSpace(courseID) || strings.TrimSpace(stringValue(doc["moduleId"])) != strings.TrimSpace(moduleID) {
return nil, ErrNotFound
}
return doc, nil
}
func (a *app) getLearnerCourseContent(w http.ResponseWriter, r *http.Request) {
userID, ok := a.learnerSubjectFromContext(r)
if !ok {
writeError(w, http.StatusUnauthorized, "missing learner subject")
return
}
courseID := strings.TrimSpace(chi.URLParam(r, "id"))
course, enrollment, access, err := a.loadLearnerCourseContentAccess(r, userID, courseID)
if err != nil {
a.writeLearningNotFound(w, err, "live course")
return
}
if allowed, _ := access["allowed"].(bool); !allowed {
writeError(w, http.StatusForbidden, stringValue(access["message"]))
return
}
modules, err := a.list("course_modules", Doc{"courseId": courseID, "status": "published"}, Sort{{Key: "position", Value: 1}, {Key: "createdAt", Value: 1}})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load course modules")
return
}
lessons, err := a.list("course_lessons", Doc{"courseId": courseID, "status": "published"}, Sort{{Key: "position", Value: 1}, {Key: "createdAt", Value: 1}})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load course lessons")
return
}
progress, err := a.list("course_lesson_progress", Doc{"courseId": courseID, "userId": userID}, Sort{{Key: "updatedAt", Value: -1}})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load lesson progress")
return
}
progressByLesson := map[string]Doc{}
for _, item := range progress {
progressByLesson[stringValue(item["lessonId"])] = item
}
moduleItems := make([]Doc, 0, len(modules))
for _, module := range modules {
moduleID := stringValue(module["id"])
moduleLessons := []Doc{}
for _, lesson := range lessons {
if stringValue(lesson["moduleId"]) != moduleID {
continue
}
item := Doc{"lesson": lesson}
if p := progressByLesson[stringValue(lesson["id"])]; p != nil {
item["progress"] = p
}
moduleLessons = append(moduleLessons, item)
}
moduleItems = append(moduleItems, Doc{"module": module, "lessons": moduleLessons})
}
writeJSON(w, http.StatusOK, Doc{
"course": course,
"enrollment": enrollment,
"modules": moduleItems,
"progress": courseLessonProgressSummary(lessons, progress),
})
}
func (a *app) completeLearnerCourseLesson(w http.ResponseWriter, r *http.Request) {
userID, ok := a.learnerSubjectFromContext(r)
if !ok {
writeError(w, http.StatusUnauthorized, "missing learner subject")
return
}
courseID, lessonID := strings.TrimSpace(chi.URLParam(r, "id")), strings.TrimSpace(chi.URLParam(r, "lessonId"))
_, enrollment, access, err := a.loadLearnerCourseContentAccess(r, userID, courseID)
if err != nil {
a.writeLearningNotFound(w, err, "live course")
return
}
if allowed, _ := access["allowed"].(bool); !allowed {
writeError(w, http.StatusForbidden, stringValue(access["message"]))
return
}
lesson, err := a.getByID("course_lessons", lessonID)
if err != nil || stringValue(lesson["courseId"]) != courseID || !strings.EqualFold(stringValue(lesson["status"]), "published") {
writeError(w, http.StatusNotFound, "course lesson not found")
return
}
now := time.Now().UnixMilli()
filter := Doc{"courseId": courseID, "lessonId": lessonID, "userId": userID}
existing, err := a.list("course_lesson_progress", filter, Sort{{Key: "updatedAt", Value: -1}})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load lesson progress")
return
}
payload := Doc{
"courseId": courseID,
"moduleId": lesson["moduleId"],
"lessonId": lessonID,
"userId": userID,
"status": "completed",
"progress": 100,
"completedAt": now,
"lastViewedAt": now,
}
if tenantID := tenantIDForLearningRequest(r); tenantID != "" {
payload["tenantId"] = tenantID
}
var saved Doc
if len(existing) > 0 {
saved, err = a.patch("course_lesson_progress", stringValue(existing[0]["id"]), payload)
} else {
payload["startedAt"] = now
saved, err = a.create("course_lesson_progress", payload)
}
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to save lesson progress")
return
}
lessons, _ := a.list("course_lessons", Doc{"courseId": courseID, "status": "published"}, nil)
progress, _ := a.list("course_lesson_progress", Doc{"courseId": courseID, "userId": userID}, nil)
summary := courseLessonProgressSummary(lessons, progress)
percent, _ := numericValue(summary["percent"])
if enrollment != nil {
patch := Doc{"progress": percent}
if percent >= 100 {
patch["status"] = "completed"
patch["completedAt"] = now
}
_, _ = a.patch("live_course_enrollments", stringValue(enrollment["id"]), patch)
}
writeJSON(w, http.StatusOK, Doc{"lessonProgress": saved, "courseProgress": summary})
}
func courseLessonProgressSummary(lessons, progress []Doc) Doc {
total := len(lessons)
completedIDs := map[string]struct{}{}
for _, item := range progress {
if strings.EqualFold(stringValue(item["status"]), "completed") {
completedIDs[stringValue(item["lessonId"])] = struct{}{}
}
}
completed := 0
for _, lesson := range lessons {
if _, ok := completedIDs[stringValue(lesson["id"])]; ok {
completed++
}
}
percent := 0.0
if total > 0 {
percent = float64(completed) * 100 / float64(total)
}
return Doc{"total": total, "completed": completed, "percent": percent}
}
func (a *app) listLearnerCourseAssets(w http.ResponseWriter, r *http.Request) {
userID, ok := a.learnerSubjectFromContext(r)
if !ok {
writeError(w, http.StatusUnauthorized, "missing learner subject")
return
}
courseID := strings.TrimSpace(chi.URLParam(r, "id"))
course, enrollment, access, err := a.loadLearnerCourseContentAccess(r, userID, courseID)
if err != nil {
a.writeLearningNotFound(w, err, "live course")
return
}
if allowed, _ := access["allowed"].(bool); !allowed {
writeError(w, http.StatusForbidden, stringValue(access["message"]))
return
}
ids, _, _ := normalizeStringSlice(course["assetIds"])
if len(ids) == 0 {
writeJSON(w, http.StatusOK, []Doc{})
return
}
assets, err := a.list("assets", Doc{"id": Doc{"$in": ids}, "status": "active"}, Sort{{Key: "updatedAt", Value: -1}})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load course assets")
return
}
out := []Doc{}
for _, asset := range assets {
allowed, err := a.canLearnerAccessCourseAsset(r, userID, enrollment, asset)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to evaluate asset access")
return
}
if !allowed {
continue
}
allowDownload, _ := asset["allowDownload"].(bool)
out = append(out, Doc{
"id": asset["id"], "title": asset["title"], "description": asset["description"],
"fileName": asset["fileName"], "visibility": asset["visibility"],
"protectionLevel": asset["protectionLevel"], "allowDownload": allowDownload,
})
}
writeJSON(w, http.StatusOK, out)
}
func (a *app) accessLearnerCourseAsset(w http.ResponseWriter, r *http.Request) {
userID, ok := a.learnerSubjectFromContext(r)
if !ok {
writeError(w, http.StatusUnauthorized, "missing learner subject")
return
}
courseID, assetID := strings.TrimSpace(chi.URLParam(r, "id")), strings.TrimSpace(chi.URLParam(r, "assetId"))
course, enrollment, access, err := a.loadLearnerCourseContentAccess(r, userID, courseID)
if err != nil {
a.writeLearningNotFound(w, err, "live course")
return
}
if allowed, _ := access["allowed"].(bool); !allowed {
writeError(w, http.StatusForbidden, stringValue(access["message"]))
return
}
ids, _, _ := normalizeStringSlice(course["assetIds"])
linked := false
for _, id := range ids {
if id == assetID {
linked = true
break
}
}
if !linked {
writeError(w, http.StatusNotFound, "course asset not found")
return
}
asset, err := a.getByID("assets", assetID)
if err != nil {
a.writeLearningNotFound(w, err, "asset")
return
}
allowed, err := a.canLearnerAccessCourseAsset(r, userID, enrollment, asset)
if err != nil || !allowed {
writeError(w, http.StatusForbidden, "asset access denied")
return
}
allowDownload, _ := asset["allowDownload"].(bool)
sourceURL := strings.TrimSpace(stringValue(asset["sourceUrl"]))
if !allowDownload || sourceURL == "" {
writeError(w, http.StatusForbidden, "asset download is not enabled")
return
}
event := Doc{"assetId": assetID, "userId": userID, "eventType": "course_download", "submittedAt": time.Now().UnixMilli(), "metadata": Doc{"courseId": courseID}}
if tenantID := tenantIDForLearningRequest(r); tenantID != "" {
event["tenantId"] = tenantID
}
_, _ = a.create("asset_access_events", event)
writeJSON(w, http.StatusOK, Doc{"url": sourceURL})
}
func (a *app) canLearnerAccessCourseAsset(r *http.Request, userID string, enrollment, asset Doc) (bool, error) {
visibility := strings.ToLower(strings.TrimSpace(stringValue(asset["visibility"])))
switch visibility {
case "", "public", "authenticated":
return true, nil
case "enrolled":
return enrollment != nil, nil
case "entitled":
key := strings.TrimSpace(stringValue(asset["entitlementKey"]))
if key == "" {
return false, nil
}
return a.hasActiveEntitlement(userID, key, time.Now().UTC())
case "instructor":
claims, _ := r.Context().Value(userClaimsKey).(*accessClaims)
return hasRole(claims, "instructor") || hasRole(claims, "admin"), nil
case "admin":
claims, _ := r.Context().Value(userClaimsKey).(*accessClaims)
return hasRole(claims, "admin"), nil
default:
return false, nil
}
}
func (a *app) loadLearnerCourseContentAccess(r *http.Request, userID, courseID string) (Doc, Doc, Doc, error) {
course, err := a.getByID("live_courses", courseID)
if err != nil {
return nil, nil, nil, err
}
enrollments, err := a.list("live_course_enrollments", Doc{
"userId": userID,
"$or": List{Doc{"liveCourseId": courseID}, Doc{"courseId": courseID}},
"status": Doc{"$in": List{"enrolled", "completed"}},
}, Sort{{Key: "updatedAt", Value: -1}})
if err != nil {
return nil, nil, nil, err
}
enrollment := firstDocument(enrollments)
entitled, reason, message, err := a.learnerEntitlementDecision(r, userID, resolveEntitlementKey(course, nil))
if err != nil {
return nil, nil, nil, err
}
return course, enrollment, evaluateLearnerAccess(course, nil, enrollment, entitled, reason, message), nil
}
func (a *app) writeLearningNotFound(w http.ResponseWriter, err error, resource string) {
if errors.Is(err, ErrNotFound) {
writeError(w, http.StatusNotFound, resource+" not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to load "+resource)
}

View File

@ -40,10 +40,12 @@ func buildRelationalResourceSpecs() map[string]relationalResourceSpec {
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE")}),
resource("datasources", []string{"name", "description", "type", "status", "config"}, []string{"config"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE")}),
resource("scorm_courses", []string{"title", "description", "status", "version", "visibility", "launchUrl", "scos", "manifest"}, []string{"scos", "manifest"},
resource("scorm_courses", []string{"title", "description", "status", "version", "visibility", "launchUrl", "subject", "gradeLevel", "enabledModuleTypes", "language", "author", "organization", "settings", "scos", "manifest"}, []string{"enabledModuleTypes", "settings", "scos", "manifest"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE")}),
resource("scorm_exports", []string{"courseId", "version", "storageKey"}, nil,
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "courseId": ref("scorm_courses", "CASCADE")}),
resource("scorm_runtime_states", []string{"userId", "courseId", "enrollmentId", "scoId", "status", "location", "suspendData", "lessonStatus", "completionStatus", "successStatus", "score", "progress", "sessionTimeSeconds", "totalTimeSeconds", "lastCommittedAt", "values"}, []string{"values"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "userId": ref("users", "CASCADE"), "courseId": ref("scorm_courses", "CASCADE"), "enrollmentId": ref("enrollments", "CASCADE")}),
resource("enrollments", []string{"userId", "courseId", "courseType", "status", "score", "progress", "progressNote", "timeSpentMin", "completedAt", "archivedAt", "archivedReason", "erasedUserId", "gdprErasedAt"}, nil,
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "userId": ref("users", "CASCADE")}),
resource("authoring_runs", []string{"userId", "status", "brief", "proposalRevision", "source", "reviewUrl", "requiresCMSApproval", "proposal", "validation", "idempotencyKeys", "theme", "site", "page"}, []string{"proposal", "validation", "idempotencyKeys", "theme", "site", "page"},
@ -98,11 +100,17 @@ func buildRelationalResourceSpecs() map[string]relationalResourceSpec {
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "userId": ref("users", "CASCADE"), "convertedFromEntitlementId": ref("entitlements", "SET NULL")}),
resource("live_courses", []string{"title", "slug", "status", "visibility", "description", "deliveryMode", "level", "durationSummary", "communityId", "entitlementKey", "priceId", "organisationId", "publishedAt", "metadata"}, []string{"metadata"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE")}),
resource("course_modules", []string{"courseId", "title", "description", "position", "status", "metadata"}, []string{"metadata"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "courseId": ref("live_courses", "CASCADE")}),
resource("course_lessons", []string{"courseId", "moduleId", "title", "description", "position", "status", "contentType", "content", "sourceUrl", "durationMinutes", "metadata"}, []string{"metadata"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "courseId": ref("live_courses", "CASCADE"), "moduleId": ref("course_modules", "CASCADE")}),
resource("course_lesson_progress", []string{"courseId", "moduleId", "lessonId", "userId", "status", "progress", "startedAt", "completedAt", "lastViewedAt", "metadata"}, []string{"metadata"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "courseId": ref("live_courses", "CASCADE"), "moduleId": ref("course_modules", "CASCADE"), "lessonId": ref("course_lessons", "CASCADE"), "userId": ref("users", "CASCADE")}),
resource("live_course_cohorts", []string{"courseId", "liveCourseId", "name", "status", "startsAt", "endsAt", "timezone", "capacity", "enrolledCount", "waitlistEnabled", "priceId", "meetingProvider", "defaultMeetingUrl", "organisationId", "metadata"}, []string{"metadata"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "courseId": ref("live_courses", "CASCADE"), "liveCourseId": ref("live_courses", "CASCADE")}),
resource("live_course_sessions", []string{"courseId", "liveCourseId", "cohortId", "title", "status", "startsAt", "endsAt", "meetingUrl", "recordingAssetId", "communityThreadId", "metadata"}, []string{"metadata"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "cohortId": ref("live_course_cohorts", "CASCADE"), "recordingAssetId": ref("assets", "SET NULL")}),
resource("live_course_enrollments", []string{"courseId", "liveCourseId", "cohortId", "userId", "status", "source", "enrolledAt", "completedAt", "metadata"}, []string{"metadata"},
resource("live_course_enrollments", []string{"courseId", "liveCourseId", "cohortId", "userId", "status", "source", "progress", "enrolledAt", "completedAt", "metadata"}, []string{"metadata"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "cohortId": ref("live_course_cohorts", "CASCADE"), "userId": ref("users", "CASCADE")}),
resource("live_course_attendance", []string{"courseId", "liveCourseId", "cohortId", "sessionId", "enrollmentId", "userId", "status", "joinedAt", "leftAt", "durationSeconds", "notes", "markedBy", "metadata"}, []string{"metadata"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "sessionId": ref("live_course_sessions", "CASCADE"), "enrollmentId": ref("live_course_enrollments", "CASCADE"), "userId": ref("users", "CASCADE")}),
@ -112,9 +120,9 @@ func buildRelationalResourceSpecs() map[string]relationalResourceSpec {
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "communityId": ref("communities", "CASCADE")}),
resource("community_memberships", []string{"communityId", "userId", "role", "status", "source", "metadata"}, []string{"metadata"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "communityId": ref("communities", "CASCADE"), "userId": ref("users", "CASCADE")}),
resource("community_posts", []string{"communityId", "channelId", "authorId", "body", "status", "metadata"}, []string{"metadata"},
resource("community_posts", []string{"communityId", "channelId", "authorId", "body", "status", "pinned", "editedAt", "metadata"}, []string{"metadata"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "communityId": ref("communities", "CASCADE"), "channelId": ref("community_channels", "CASCADE"), "authorId": ref("users", "SET NULL")}),
resource("community_replies", []string{"postId", "authorId", "body", "status", "metadata"}, []string{"metadata"},
resource("community_replies", []string{"postId", "authorId", "body", "status", "editedAt", "metadata"}, []string{"metadata"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "postId": ref("community_posts", "CASCADE"), "authorId": ref("users", "SET NULL")}),
resource("keycloak_themes", []string{"name", "slug", "version", "status", "scope", "type", "target", "organizationId", "realm", "client", "createdBy", "updatedBy", "sections", "tokens", "metadata"}, []string{"target", "sections", "tokens", "metadata"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE")}),
@ -273,6 +281,8 @@ func isBooleanResourceField(resourceName, field string) bool {
return field == "waitlistEnabled"
case "community_channels":
return field == "isPrivate"
case "community_posts":
return field == "pinned"
case "authoring_runs":
return field == "requiresCMSApproval"
case "blog_posts":
@ -296,7 +306,7 @@ func isNumericField(field string) bool {
"attemptCount", "retryCount", "throughputPerMinute", "burst", "maxRetries",
"backoffSeconds", "maxBackoffSeconds", "deferredSeconds", "perTemplatePerHour",
"perRecipientPerHour", "quietHoursStartUtc", "quietHoursEndUtc", "durationDays",
"trialDays", "graceDays", "limit":
"trialDays", "graceDays", "limit", "position", "durationMinutes", "sessionTimeSeconds", "totalTimeSeconds":
return true
default:
return false
@ -341,8 +351,13 @@ func (d *pgDatabase) ensureRelationalSchema(ctx context.Context) error {
return fmt.Errorf("migrate legacy document store: %w", err)
}
_, err := d.pool.Exec(ctx, `INSERT INTO schema_migrations(version)
if _, err := d.pool.Exec(ctx, `INSERT INTO schema_migrations(version)
VALUES ('20260928_relational_v1')
ON CONFLICT (version) DO NOTHING`); err != nil {
return err
}
_, err := d.pool.Exec(ctx, `INSERT INTO schema_migrations(version)
VALUES ('20260928_learning_runtime_v2')
ON CONFLICT (version) DO NOTHING`)
return err
}
@ -486,7 +501,24 @@ func (d *pgDatabase) ensureRelationalTable(ctx context.Context, spec relationalR
return err
}
for _, field := range []string{"tenantId", "updatedAt", "status", "slug", "userId", "courseId", "communityId", "cohortId"} {
// CREATE TABLE IF NOT EXISTS does not evolve an existing production table.
// Add newly declared relational fields idempotently before constraints/indexes.
for _, field := range spec.columnFields() {
if field == "id" {
continue
}
column := relationalColumnName(field)
definition := fmt.Sprintf("%s %s", relationalIdent(column), relationalSQLType(spec, field))
if field == "createdAt" || field == "updatedAt" {
definition += " NOT NULL DEFAULT 0"
}
alter := fmt.Sprintf("ALTER TABLE %s ADD COLUMN IF NOT EXISTS %s", relationalIdent(spec.Table), definition)
if _, err := d.pool.Exec(ctx, alter); err != nil {
return fmt.Errorf("add column %s.%s: %w", spec.Table, column, err)
}
}
for _, field := range []string{"tenantId", "updatedAt", "status", "slug", "userId", "courseId", "communityId", "cohortId", "moduleId", "lessonId", "scoId"} {
if !spec.hasColumn(field) {
continue
}
@ -535,11 +567,11 @@ func relationalTableRank(name string) int {
return 0
case "users", "blog_categories", "blog_tags", "blog_authors", "forms", "scorm_courses", "assets", "communities", "live_courses", "notification_templates", "billing_customers", "keycloak_themes":
return 10
case "community_channels", "live_course_cohorts", "notification_deliveries":
case "community_channels", "live_course_cohorts", "notification_deliveries", "course_modules":
return 20
case "live_course_sessions", "live_course_enrollments", "community_posts", "notification_queue":
case "live_course_sessions", "live_course_enrollments", "community_posts", "notification_queue", "course_lessons", "scorm_runtime_states":
return 30
case "live_course_attendance", "community_replies", "notification_delivery_attempts":
case "live_course_attendance", "community_replies", "notification_delivery_attempts", "course_lesson_progress":
return 40
default:
return 50

View File

@ -18,5 +18,11 @@ func (a *app) registerCommunityRoutes(r chi.Router) {
r.Get("/{id}/memberships", a.listCommunityMemberships)
r.Post("/{id}/memberships", a.createCommunityMembership)
r.Patch("/{id}/memberships/{membershipId}", a.patchCommunityMembership)
r.Get("/{id}/channels/{channelId}/posts", a.listAdminCommunityPosts)
r.Post("/{id}/channels/{channelId}/posts", a.createAdminCommunityPost)
r.Patch("/{id}/posts/{postId}", a.patchAdminCommunityPost)
r.Get("/{id}/posts/{postId}/replies", a.listAdminCommunityReplies)
r.Post("/{id}/posts/{postId}/replies", a.createAdminCommunityReply)
r.Patch("/{id}/replies/{replyId}", a.patchAdminCommunityReply)
})
}

View File

@ -7,6 +7,10 @@ func (a *app) registerLearnerRoutes(r chi.Router) {
r.Use(a.liveCourseLearnerMiddleware)
r.Get("/", a.listLearnerLiveCourses)
r.Get("/{id}", a.getLearnerLiveCourse)
r.Get("/{id}/content", a.getLearnerCourseContent)
r.Post("/{id}/lessons/{lessonId}/complete", a.completeLearnerCourseLesson)
r.Get("/{id}/assets", a.listLearnerCourseAssets)
r.Post("/{id}/assets/{assetId}/access", a.accessLearnerCourseAsset)
r.Get("/{id}/sessions", a.listLearnerLiveCourseSessions)
r.Get("/{id}/sessions/{sessionId}", a.getLearnerLiveCourseSession)
r.Post("/{id}/sessions/{sessionId}/join", a.joinLearnerLiveCourseSession)
@ -16,6 +20,10 @@ func (a *app) registerLearnerRoutes(r chi.Router) {
r.Use(a.communityLearnerMiddleware)
r.Get("/", a.listLearnerCommunities)
r.Get("/{id}/channels", a.listLearnerCommunityChannels)
r.Get("/{id}/channels/{channelId}/posts", a.listLearnerCommunityPosts)
r.Post("/{id}/channels/{channelId}/posts", a.createLearnerCommunityPost)
r.Get("/{id}/posts/{postId}/replies", a.listLearnerCommunityReplies)
r.Post("/{id}/posts/{postId}/replies", a.createLearnerCommunityReply)
r.Post("/{id}/join", a.joinLearnerCommunity)
})
@ -23,5 +31,7 @@ func (a *app) registerLearnerRoutes(r chi.Router) {
r.Use(a.scormLearnerMiddleware)
r.Get("/", a.listLearnerScormCourses)
r.Get("/{id}/launch", a.getLearnerScormLaunch)
r.Get("/{id}/runtime", a.getLearnerScormRuntime)
r.Patch("/{id}/runtime", a.commitLearnerScormRuntime)
})
}

View File

@ -11,6 +11,15 @@ func (a *app) registerLiveCourseRoutes(r chi.Router) {
r.Patch("/{id}", a.patchLiveCourse)
r.Delete("/{id}", a.deleteLiveCourse)
r.Get("/{id}/modules", a.listLiveCourseModules)
r.Post("/{id}/modules", a.createLiveCourseModule)
r.Patch("/{id}/modules/{moduleId}", a.patchLiveCourseModule)
r.Delete("/{id}/modules/{moduleId}", a.deleteLiveCourseModule)
r.Get("/{id}/modules/{moduleId}/lessons", a.listLiveCourseLessons)
r.Post("/{id}/modules/{moduleId}/lessons", a.createLiveCourseLesson)
r.Patch("/{id}/modules/{moduleId}/lessons/{lessonId}", a.patchLiveCourseLesson)
r.Delete("/{id}/modules/{moduleId}/lessons/{lessonId}", a.deleteLiveCourseLesson)
r.Get("/{id}/cohorts", a.listLiveCourseCohorts)
r.Post("/{id}/cohorts", a.createLiveCourseCohort)
r.Get("/{id}/cohorts/{cohortId}", a.getLiveCourseCohort)

View File

@ -0,0 +1,292 @@
package main
import (
"errors"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
)
func (a *app) getLearnerScormRuntime(w http.ResponseWriter, r *http.Request) {
userID, ok := a.learnerSubjectFromContext(r)
if !ok {
writeError(w, http.StatusUnauthorized, "missing learner subject")
return
}
courseID := strings.TrimSpace(chi.URLParam(r, "id"))
course, enrollment, _, access, err := a.loadLearnerScormRuntimeContext(r, userID, courseID)
if err != nil {
a.writeLearningNotFound(w, err, "SCORM course")
return
}
if allowed, _ := access["allowed"].(bool); !allowed {
writeError(w, http.StatusForbidden, stringValue(access["message"]))
return
}
scoID := strings.TrimSpace(r.URL.Query().Get("scoId"))
if scoID == "" {
scoID = firstScormSCOID(course)
}
if scoID == "" || !scormCourseHasSCO(course, scoID) {
writeError(w, http.StatusNotFound, "SCORM SCO not found")
return
}
states, err := a.list("scorm_runtime_states", Doc{"userId": userID, "courseId": courseID, "scoId": scoID}, Sort{{Key: "updatedAt", Value: -1}})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load SCORM runtime")
return
}
if len(states) == 0 {
writeJSON(w, http.StatusOK, defaultScormRuntimeState(userID, courseID, stringValue(enrollment["id"]), scoID))
return
}
writeJSON(w, http.StatusOK, states[0])
}
func (a *app) commitLearnerScormRuntime(w http.ResponseWriter, r *http.Request) {
userID, ok := a.learnerSubjectFromContext(r)
if !ok {
writeError(w, http.StatusUnauthorized, "missing learner subject")
return
}
courseID := strings.TrimSpace(chi.URLParam(r, "id"))
course, enrollment, _, access, err := a.loadLearnerScormRuntimeContext(r, userID, courseID)
if err != nil {
a.writeLearningNotFound(w, err, "SCORM course")
return
}
if allowed, _ := access["allowed"].(bool); !allowed {
writeError(w, http.StatusForbidden, stringValue(access["message"]))
return
}
var body map[string]any
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid SCORM runtime payload")
return
}
scoID := strings.TrimSpace(stringValue(body["scoId"]))
if scoID == "" {
scoID = firstScormSCOID(course)
}
if scoID == "" || !scormCourseHasSCO(course, scoID) {
writeError(w, http.StatusBadRequest, "scoId must identify a SCO in this course")
return
}
states, err := a.list("scorm_runtime_states", Doc{"userId": userID, "courseId": courseID, "scoId": scoID}, Sort{{Key: "updatedAt", Value: -1}})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load SCORM runtime")
return
}
state := defaultScormRuntimeState(userID, courseID, stringValue(enrollment["id"]), scoID)
var existingID string
if len(states) > 0 {
existingID = stringValue(states[0]["id"])
for k, v := range states[0] {
state[k] = v
}
}
for _, field := range []string{"location", "suspendData", "lessonStatus", "completionStatus", "successStatus", "status"} {
if value, ok := body[field].(string); ok {
state[field] = strings.TrimSpace(value)
}
}
for _, field := range []string{"score", "progress"} {
if raw, exists := body[field]; exists {
if value, ok := numericValue(raw); ok {
if value < 0 {
value = 0
}
if value > 100 {
value = 100
}
state[field] = value
}
}
}
for _, field := range []string{"sessionTimeSeconds", "totalTimeSeconds"} {
if raw, exists := body[field]; exists {
if value, ok := numericValue(raw); ok {
if value < 0 {
value = 0
}
state[field] = int64(value)
}
}
}
if rawValues, exists := body["values"]; exists {
values, ok := asMap(rawValues)
if !ok {
writeError(w, http.StatusBadRequest, "values must be an object")
return
}
merged := Doc{}
if existing, ok := asMap(state["values"]); ok {
for k, v := range existing {
merged[k] = v
}
}
for k, v := range values {
merged[k] = v
}
state["values"] = merged
}
state["lastCommittedAt"] = time.Now().UnixMilli()
if tenantID := tenantIDForLearningRequest(r); tenantID != "" {
state["tenantId"] = tenantID
}
delete(state, "id")
delete(state, "createdAt")
delete(state, "updatedAt")
var saved Doc
if existingID != "" {
saved, err = a.patch("scorm_runtime_states", existingID, state)
} else {
saved, err = a.create("scorm_runtime_states", state)
}
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to save SCORM runtime")
return
}
summary, err := a.updateScormEnrollmentFromRuntime(course, enrollment, userID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to update SCORM progress")
return
}
writeJSON(w, http.StatusOK, Doc{"runtime": saved, "courseProgress": summary})
}
func defaultScormRuntimeState(userID, courseID, enrollmentID, scoID string) Doc {
return Doc{
"userId": userID,
"courseId": courseID,
"enrollmentId": enrollmentID,
"scoId": scoID,
"status": "initialized",
"location": "",
"suspendData": "",
"lessonStatus": "incomplete",
"completionStatus": "incomplete",
"successStatus": "unknown",
"score": 0,
"progress": 0,
"sessionTimeSeconds": 0,
"totalTimeSeconds": 0,
"values": Doc{},
}
}
func (a *app) loadLearnerScormRuntimeContext(r *http.Request, userID, courseID string) (Doc, Doc, []Doc, Doc, error) {
course, err := a.getByID("scorm_courses", courseID)
if err != nil {
return nil, nil, nil, nil, err
}
enrollments, err := a.list("enrollments", Doc{"userId": userID, "courseId": courseID}, Sort{{Key: "updatedAt", Value: -1}})
if err != nil {
return nil, nil, nil, nil, err
}
if len(enrollments) == 0 {
return course, nil, nil, learnerAccessResult(false, "missing_enrollment", "You are not enrolled in this SCORM course."), nil
}
enrollment := enrollments[0]
exports, err := a.list("scorm_exports", Doc{"courseId": courseID}, Sort{{Key: "createdAt", Value: -1}})
if err != nil {
return nil, nil, nil, nil, err
}
entitled, reason, message, err := a.learnerEntitlementDecision(r, userID, resolveEntitlementKey(course, nil))
if err != nil {
return nil, nil, nil, nil, err
}
return course, enrollment, exports, evaluateLearnerScormLaunch(course, enrollment, exports, entitled, reason, message), nil
}
func firstScormSCOID(course Doc) string {
raw := course["scos"]
switch list := raw.(type) {
case []any:
if len(list) > 0 {
if item, ok := asMap(list[0]); ok {
return stringValue(item["id"])
}
}
case List:
if len(list) > 0 {
if item, ok := asMap(list[0]); ok {
return stringValue(item["id"])
}
}
}
return ""
}
func scormCourseHasSCO(course Doc, scoID string) bool {
raw := course["scos"]
var list []any
switch value := raw.(type) {
case []any:
list = value
case List:
list = []any(value)
}
for _, rawItem := range list {
item, ok := asMap(rawItem)
if ok && stringValue(item["id"]) == scoID {
return true
}
}
return false
}
func scormRuntimeComplete(state Doc) bool {
completion := strings.ToLower(strings.TrimSpace(stringValue(state["completionStatus"])))
lesson := strings.ToLower(strings.TrimSpace(stringValue(state["lessonStatus"])))
return completion == "completed" || completion == "passed" || lesson == "completed" || lesson == "passed" || lesson == "failed"
}
func (a *app) updateScormEnrollmentFromRuntime(course, enrollment Doc, userID string) (Doc, error) {
courseID := stringValue(course["id"])
states, err := a.list("scorm_runtime_states", Doc{"courseId": courseID, "userId": userID}, nil)
if err != nil {
return nil, err
}
total := 0
switch scos := course["scos"].(type) {
case []any:
total = len(scos)
case List:
total = len(scos)
}
completed := 0
scoreTotal := 0.0
scoreCount := 0
for _, state := range states {
if scormRuntimeComplete(state) {
completed++
}
if score, ok := numericValue(state["score"]); ok && score > 0 {
scoreTotal += score
scoreCount++
}
}
percent := 0.0
if total > 0 {
percent = float64(completed) * 100 / float64(total)
}
patch := Doc{"progress": percent}
if scoreCount > 0 {
patch["score"] = scoreTotal / float64(scoreCount)
}
if total > 0 && completed >= total {
patch["status"] = "completed"
patch["completedAt"] = time.Now().UnixMilli()
} else if percent > 0 {
patch["status"] = "in_progress"
}
if enrollment != nil && stringValue(enrollment["id"]) != "" {
if _, err := a.patch("enrollments", stringValue(enrollment["id"]), patch); err != nil && !errors.Is(err, ErrNotFound) {
return nil, err
}
}
return Doc{"total": total, "completed": completed, "percent": percent, "score": patch["score"]}, nil
}

View File

@ -0,0 +1,13 @@
import { apiRequest, createResource, listResource, patchResource } from './api/client'
export type AdminCommunity = { id: string; name: string; description?: string; status?: string; visibility?: string; accessPolicy?: string }
export type AdminCommunityChannel = { id: string; communityId: string; name: string; topic?: string; isPrivate?: boolean }
export type AdminCommunityPost = { id: string; body: string; status?: string; pinned?: boolean; author?: { name?: string }; replies?: unknown[] }
export async function fetchAdminCommunities() { return await listResource<AdminCommunity>('communities') }
export async function createAdminCommunity(payload: Record<string, unknown>) { return await createResource<AdminCommunity>('communities', payload) }
export async function patchAdminCommunity(id: string, payload: Record<string, unknown>) { return await patchResource<AdminCommunity>('communities', id, payload) }
export async function fetchAdminCommunityChannels(communityID: string) { return await apiRequest<AdminCommunityChannel[]>(`/api/communities/${encodeURIComponent(communityID)}/channels`) }
export async function createAdminCommunityChannel(communityID: string, payload: Record<string, unknown>) { return await apiRequest<AdminCommunityChannel>(`/api/communities/${encodeURIComponent(communityID)}/channels`, { method: 'POST', body: JSON.stringify(payload) }) }
export async function fetchAdminCommunityPosts(communityID: string, channelID: string) { return await apiRequest<AdminCommunityPost[]>(`/api/communities/${encodeURIComponent(communityID)}/channels/${encodeURIComponent(channelID)}/posts`) }
export async function patchAdminCommunityPost(communityID: string, postID: string, payload: Record<string, unknown>) { return await apiRequest<AdminCommunityPost>(`/api/communities/${encodeURIComponent(communityID)}/posts/${encodeURIComponent(postID)}`, { method: 'PATCH', body: JSON.stringify(payload) }) }

View File

@ -0,0 +1,8 @@
import { apiRequest } from './api/client'
export type NotificationTemplate = { id: string; name: string; channel?: string; subject?: string; body?: string; enabled?: boolean }
export type NotificationDelivery = { id: string; channel?: string; to?: string; subject?: string; status?: string; provider?: string; queuedAt?: number; sentAt?: number; lastError?: string }
export async function fetchNotificationTemplates() { return await apiRequest<NotificationTemplate[]>('/api/notifications/templates') }
export async function createNotificationTemplate(payload: Record<string, unknown>) { return await apiRequest<NotificationTemplate>('/api/notifications/templates', { method: 'POST', body: JSON.stringify(payload) }) }
export async function patchNotificationTemplate(id: string, payload: Record<string, unknown>) { return await apiRequest<NotificationTemplate>(`/api/notifications/templates/${encodeURIComponent(id)}`, { method: 'PATCH', body: JSON.stringify(payload) }) }
export async function fetchNotificationDeliveries() { return await apiRequest<NotificationDelivery[]>('/api/notifications/deliveries') }
export async function createNotificationDelivery(payload: Record<string, unknown>) { return await apiRequest<NotificationDelivery>('/api/notifications/deliveries', { method: 'POST', body: JSON.stringify(payload) }) }