470 lines
15 KiB
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}
|
|
}
|