sync: learning operations backend 1/2
This commit is contained in:
parent
2fdd51f921
commit
7c7aec1984
469
Backend/cmd/api/community_conversations.go
Normal file
469
Backend/cmd/api/community_conversations.go
Normal 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}
|
||||
}
|
||||
626
Backend/cmd/api/learning_content.go
Normal file
626
Backend/cmd/api/learning_content.go
Normal 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)
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
292
Backend/cmd/api/scorm_runtime.go
Normal file
292
Backend/cmd/api/scorm_runtime.go
Normal 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
|
||||
}
|
||||
13
Frontend/src/app/admin-communities.ts
Normal file
13
Frontend/src/app/admin-communities.ts
Normal 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) }) }
|
||||
8
Frontend/src/app/admin-notifications.ts
Normal file
8
Frontend/src/app/admin-notifications.ts
Normal 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) }) }
|
||||
Loading…
x
Reference in New Issue
Block a user