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