learn-platform-deploy/Backend/cmd/api/community_conversations.go

470 lines
15 KiB
Go

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