From 7c7aec19845e40896df25d746e791c9ff413981c Mon Sep 17 00:00:00 2001 From: Admin Date: Sun, 27 Sep 2026 23:14:16 +0000 Subject: [PATCH] sync: learning operations backend 1/2 --- Backend/cmd/api/community_conversations.go | 469 +++++++++++++++ Backend/cmd/api/learning_content.go | 626 +++++++++++++++++++++ Backend/cmd/api/relational_schema.go | 52 +- Backend/cmd/api/routes_api_communities.go | 6 + Backend/cmd/api/routes_api_learner.go | 10 + Backend/cmd/api/routes_api_live_courses.go | 9 + Backend/cmd/api/scorm_runtime.go | 292 ++++++++++ Frontend/src/app/admin-communities.ts | 13 + Frontend/src/app/admin-notifications.ts | 8 + 9 files changed, 1475 insertions(+), 10 deletions(-) create mode 100644 Backend/cmd/api/community_conversations.go create mode 100644 Backend/cmd/api/learning_content.go create mode 100644 Backend/cmd/api/scorm_runtime.go create mode 100644 Frontend/src/app/admin-communities.ts create mode 100644 Frontend/src/app/admin-notifications.ts diff --git a/Backend/cmd/api/community_conversations.go b/Backend/cmd/api/community_conversations.go new file mode 100644 index 0000000..fa88a18 --- /dev/null +++ b/Backend/cmd/api/community_conversations.go @@ -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} +} diff --git a/Backend/cmd/api/learning_content.go b/Backend/cmd/api/learning_content.go new file mode 100644 index 0000000..2e0a072 --- /dev/null +++ b/Backend/cmd/api/learning_content.go @@ -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) +} diff --git a/Backend/cmd/api/relational_schema.go b/Backend/cmd/api/relational_schema.go index a6106eb..d37be53 100644 --- a/Backend/cmd/api/relational_schema.go +++ b/Backend/cmd/api/relational_schema.go @@ -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 diff --git a/Backend/cmd/api/routes_api_communities.go b/Backend/cmd/api/routes_api_communities.go index eb1331e..01f9ef0 100644 --- a/Backend/cmd/api/routes_api_communities.go +++ b/Backend/cmd/api/routes_api_communities.go @@ -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) }) } diff --git a/Backend/cmd/api/routes_api_learner.go b/Backend/cmd/api/routes_api_learner.go index f0a320f..8af8aed 100644 --- a/Backend/cmd/api/routes_api_learner.go +++ b/Backend/cmd/api/routes_api_learner.go @@ -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) }) } diff --git a/Backend/cmd/api/routes_api_live_courses.go b/Backend/cmd/api/routes_api_live_courses.go index a199096..6c46043 100644 --- a/Backend/cmd/api/routes_api_live_courses.go +++ b/Backend/cmd/api/routes_api_live_courses.go @@ -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) diff --git a/Backend/cmd/api/scorm_runtime.go b/Backend/cmd/api/scorm_runtime.go new file mode 100644 index 0000000..a3f0019 --- /dev/null +++ b/Backend/cmd/api/scorm_runtime.go @@ -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 +} diff --git a/Frontend/src/app/admin-communities.ts b/Frontend/src/app/admin-communities.ts new file mode 100644 index 0000000..d1034a4 --- /dev/null +++ b/Frontend/src/app/admin-communities.ts @@ -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('communities') } +export async function createAdminCommunity(payload: Record) { return await createResource('communities', payload) } +export async function patchAdminCommunity(id: string, payload: Record) { return await patchResource('communities', id, payload) } +export async function fetchAdminCommunityChannels(communityID: string) { return await apiRequest(`/api/communities/${encodeURIComponent(communityID)}/channels`) } +export async function createAdminCommunityChannel(communityID: string, payload: Record) { return await apiRequest(`/api/communities/${encodeURIComponent(communityID)}/channels`, { method: 'POST', body: JSON.stringify(payload) }) } +export async function fetchAdminCommunityPosts(communityID: string, channelID: string) { return await apiRequest(`/api/communities/${encodeURIComponent(communityID)}/channels/${encodeURIComponent(channelID)}/posts`) } +export async function patchAdminCommunityPost(communityID: string, postID: string, payload: Record) { return await apiRequest(`/api/communities/${encodeURIComponent(communityID)}/posts/${encodeURIComponent(postID)}`, { method: 'PATCH', body: JSON.stringify(payload) }) } diff --git a/Frontend/src/app/admin-notifications.ts b/Frontend/src/app/admin-notifications.ts new file mode 100644 index 0000000..c3f4345 --- /dev/null +++ b/Frontend/src/app/admin-notifications.ts @@ -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('/api/notifications/templates') } +export async function createNotificationTemplate(payload: Record) { return await apiRequest('/api/notifications/templates', { method: 'POST', body: JSON.stringify(payload) }) } +export async function patchNotificationTemplate(id: string, payload: Record) { return await apiRequest(`/api/notifications/templates/${encodeURIComponent(id)}`, { method: 'PATCH', body: JSON.stringify(payload) }) } +export async function fetchNotificationDeliveries() { return await apiRequest('/api/notifications/deliveries') } +export async function createNotificationDelivery(payload: Record) { return await apiRequest('/api/notifications/deliveries', { method: 'POST', body: JSON.stringify(payload) }) }