842 lines
25 KiB
Go
842 lines
25 KiB
Go
package main
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
)
|
|
|
|
const (
|
|
communityStatusActive = "active"
|
|
communityStatusArchived = "archived"
|
|
communityVisibilityPublic = "public"
|
|
communityVisibilityPrivate = "private"
|
|
communityAccessOpen = "open"
|
|
communityAccessRole = "role"
|
|
communityAccessEntitlement = "entitlement"
|
|
communityAccessMembership = "membership"
|
|
)
|
|
|
|
func (a *app) communitiesAdminMiddleware(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
claims, _ := r.Context().Value(userClaimsKey).(*accessClaims)
|
|
if claims == nil {
|
|
writeError(w, http.StatusUnauthorized, "no authenticated user")
|
|
return
|
|
}
|
|
if !hasRole(claims, "admin") {
|
|
writeError(w, http.StatusForbidden, "insufficient role for community management")
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
func (a *app) communityLearnerMiddleware(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
claims, _ := r.Context().Value(userClaimsKey).(*accessClaims)
|
|
if claims == nil || strings.TrimSpace(claims.Sub) == "" {
|
|
writeError(w, http.StatusUnauthorized, "no authenticated user")
|
|
return
|
|
}
|
|
if !hasRole(claims, "learner") && !hasRole(claims, "admin") && !hasRole(claims, "instructor") {
|
|
writeError(w, http.StatusForbidden, "insufficient role for community access")
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
func (a *app) listCommunities(w http.ResponseWriter, r *http.Request) {
|
|
if a.db == nil {
|
|
writeError(w, http.StatusServiceUnavailable, "database not configured")
|
|
return
|
|
}
|
|
filter := Doc{}
|
|
if status := strings.TrimSpace(strings.ToLower(r.URL.Query().Get("status"))); status != "" {
|
|
filter["status"] = status
|
|
}
|
|
docs, err := a.list("communities", filter, Sort{{Key: "updatedAt", Value: -1}, {Key: "createdAt", Value: -1}})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to list communities")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, docs)
|
|
}
|
|
|
|
func (a *app) createCommunity(w http.ResponseWriter, r *http.Request) {
|
|
if a.db == nil {
|
|
writeError(w, http.StatusServiceUnavailable, "database not configured")
|
|
return
|
|
}
|
|
var payload map[string]any
|
|
if err := decodeJSON(r, &payload); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid payload")
|
|
return
|
|
}
|
|
doc, err := prepareCommunityPayload(Doc(payload), false)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
doc, err = a.create("communities", doc)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to create community")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, doc)
|
|
}
|
|
|
|
func (a *app) getCommunity(w http.ResponseWriter, r *http.Request) {
|
|
a.getCollection("communities", w, r)
|
|
}
|
|
|
|
func (a *app) patchCommunity(w http.ResponseWriter, r *http.Request) {
|
|
if a.db == nil {
|
|
writeError(w, http.StatusServiceUnavailable, "database not configured")
|
|
return
|
|
}
|
|
id := strings.TrimSpace(chi.URLParam(r, "id"))
|
|
if id == "" {
|
|
writeError(w, http.StatusBadRequest, "community id is required")
|
|
return
|
|
}
|
|
var payload map[string]any
|
|
if err := decodeJSON(r, &payload); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid patch payload")
|
|
return
|
|
}
|
|
patch, err := prepareCommunityPayload(Doc(payload), true)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
doc, err := a.patch("communities", id, patch)
|
|
if err != nil {
|
|
if errors.Is(err, ErrNotFound) {
|
|
writeError(w, http.StatusNotFound, "resource not found")
|
|
return
|
|
}
|
|
writeError(w, http.StatusInternalServerError, "failed to update community")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, doc)
|
|
}
|
|
|
|
func (a *app) deleteCommunity(w http.ResponseWriter, r *http.Request) {
|
|
a.deleteCollection("communities", w, r)
|
|
}
|
|
|
|
func (a *app) listCommunityChannels(w http.ResponseWriter, r *http.Request) {
|
|
communityID := strings.TrimSpace(chi.URLParam(r, "id"))
|
|
if communityID == "" {
|
|
writeError(w, http.StatusBadRequest, "community id is required")
|
|
return
|
|
}
|
|
if _, err := a.getByID("communities", communityID); err != nil {
|
|
if errors.Is(err, ErrNotFound) {
|
|
writeError(w, http.StatusNotFound, "community not found")
|
|
return
|
|
}
|
|
writeError(w, http.StatusInternalServerError, "failed to load community")
|
|
return
|
|
}
|
|
docs, err := a.list("community_channels", Doc{"communityId": communityID}, Sort{{Key: "createdAt", Value: 1}, {Key: "updatedAt", Value: -1}})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to list community channels")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, docs)
|
|
}
|
|
|
|
func (a *app) createCommunityChannel(w http.ResponseWriter, r *http.Request) {
|
|
communityID := strings.TrimSpace(chi.URLParam(r, "id"))
|
|
if communityID == "" {
|
|
writeError(w, http.StatusBadRequest, "community id is required")
|
|
return
|
|
}
|
|
if _, err := a.getByID("communities", communityID); err != nil {
|
|
if errors.Is(err, ErrNotFound) {
|
|
writeError(w, http.StatusNotFound, "community not found")
|
|
return
|
|
}
|
|
writeError(w, http.StatusInternalServerError, "failed to load community")
|
|
return
|
|
}
|
|
var payload map[string]any
|
|
if err := decodeJSON(r, &payload); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid payload")
|
|
return
|
|
}
|
|
doc, err := prepareCommunityChannelPayload(communityID, Doc(payload), false)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
doc, err = a.create("community_channels", doc)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to create channel")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, doc)
|
|
}
|
|
|
|
func (a *app) getCommunityChannel(w http.ResponseWriter, r *http.Request) {
|
|
channel, err := a.getCommunityChannelByID(chi.URLParam(r, "id"), chi.URLParam(r, "channelId"))
|
|
if err != nil {
|
|
if errors.Is(err, ErrNotFound) {
|
|
writeError(w, http.StatusNotFound, "resource not found")
|
|
return
|
|
}
|
|
writeError(w, http.StatusInternalServerError, "failed to load channel")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, channel)
|
|
}
|
|
|
|
func (a *app) patchCommunityChannel(w http.ResponseWriter, r *http.Request) {
|
|
channel, err := a.getCommunityChannelByID(chi.URLParam(r, "id"), chi.URLParam(r, "channelId"))
|
|
if err != nil {
|
|
if errors.Is(err, ErrNotFound) {
|
|
writeError(w, http.StatusNotFound, "resource not found")
|
|
return
|
|
}
|
|
writeError(w, http.StatusInternalServerError, "failed to load channel")
|
|
return
|
|
}
|
|
var payload map[string]any
|
|
if err := decodeJSON(r, &payload); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid patch payload")
|
|
return
|
|
}
|
|
patch, err := prepareCommunityChannelPayload(stringValue(channel["communityId"]), Doc(payload), true)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
doc, err := a.patch("community_channels", stringValue(channel["id"]), patch)
|
|
if err != nil {
|
|
if errors.Is(err, ErrNotFound) {
|
|
writeError(w, http.StatusNotFound, "resource not found")
|
|
return
|
|
}
|
|
writeError(w, http.StatusInternalServerError, "failed to update channel")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, doc)
|
|
}
|
|
|
|
func (a *app) deleteCommunityChannel(w http.ResponseWriter, r *http.Request) {
|
|
channel, err := a.getCommunityChannelByID(chi.URLParam(r, "id"), chi.URLParam(r, "channelId"))
|
|
if err != nil {
|
|
if errors.Is(err, ErrNotFound) {
|
|
writeError(w, http.StatusNotFound, "resource not found")
|
|
return
|
|
}
|
|
writeError(w, http.StatusInternalServerError, "failed to load channel")
|
|
return
|
|
}
|
|
if err := a.deleteByID("community_channels", stringValue(channel["id"])); err != nil {
|
|
if errors.Is(err, ErrNotFound) {
|
|
writeError(w, http.StatusNotFound, "resource not found")
|
|
return
|
|
}
|
|
writeError(w, http.StatusInternalServerError, "failed to delete channel")
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (a *app) listCommunityMemberships(w http.ResponseWriter, r *http.Request) {
|
|
communityID := strings.TrimSpace(chi.URLParam(r, "id"))
|
|
if communityID == "" {
|
|
writeError(w, http.StatusBadRequest, "community id is required")
|
|
return
|
|
}
|
|
docs, err := a.list("community_memberships", Doc{"communityId": communityID}, Sort{{Key: "updatedAt", Value: -1}, {Key: "createdAt", Value: -1}})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to list memberships")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, docs)
|
|
}
|
|
|
|
func (a *app) createCommunityMembership(w http.ResponseWriter, r *http.Request) {
|
|
communityID := strings.TrimSpace(chi.URLParam(r, "id"))
|
|
if communityID == "" {
|
|
writeError(w, http.StatusBadRequest, "community id is required")
|
|
return
|
|
}
|
|
if _, err := a.getByID("communities", communityID); err != nil {
|
|
if errors.Is(err, ErrNotFound) {
|
|
writeError(w, http.StatusNotFound, "community not found")
|
|
return
|
|
}
|
|
writeError(w, http.StatusInternalServerError, "failed to load community")
|
|
return
|
|
}
|
|
var payload map[string]any
|
|
if err := decodeJSON(r, &payload); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid payload")
|
|
return
|
|
}
|
|
doc, err := prepareCommunityMembershipPayload(communityID, Doc(payload), false)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
doc, err = a.create("community_memberships", doc)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to create membership")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, doc)
|
|
}
|
|
|
|
func (a *app) patchCommunityMembership(w http.ResponseWriter, r *http.Request) {
|
|
membership, err := a.getCommunityMembershipByID(chi.URLParam(r, "id"), chi.URLParam(r, "membershipId"))
|
|
if err != nil {
|
|
if errors.Is(err, ErrNotFound) {
|
|
writeError(w, http.StatusNotFound, "resource not found")
|
|
return
|
|
}
|
|
writeError(w, http.StatusInternalServerError, "failed to load membership")
|
|
return
|
|
}
|
|
var payload map[string]any
|
|
if err := decodeJSON(r, &payload); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid patch payload")
|
|
return
|
|
}
|
|
patch, err := prepareCommunityMembershipPayload(stringValue(membership["communityId"]), Doc(payload), true)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
doc, err := a.patch("community_memberships", stringValue(membership["id"]), patch)
|
|
if err != nil {
|
|
if errors.Is(err, ErrNotFound) {
|
|
writeError(w, http.StatusNotFound, "resource not found")
|
|
return
|
|
}
|
|
writeError(w, http.StatusInternalServerError, "failed to update membership")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, doc)
|
|
}
|
|
|
|
func (a *app) listLearnerCommunities(w http.ResponseWriter, r *http.Request) {
|
|
claims, _ := r.Context().Value(userClaimsKey).(*accessClaims)
|
|
if claims == nil || strings.TrimSpace(claims.Sub) == "" {
|
|
writeError(w, http.StatusUnauthorized, "no authenticated user")
|
|
return
|
|
}
|
|
if a.db == nil {
|
|
writeError(w, http.StatusServiceUnavailable, "database not configured")
|
|
return
|
|
}
|
|
communities, err := a.list("communities", Doc{"status": communityStatusActive}, Sort{{Key: "updatedAt", Value: -1}, {Key: "createdAt", Value: -1}})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to list communities")
|
|
return
|
|
}
|
|
out := make([]Doc, 0, len(communities))
|
|
for _, community := range communities {
|
|
allowed, reason, err := a.canAccessCommunityForClaims(claims, community)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to evaluate community access")
|
|
return
|
|
}
|
|
if !allowed {
|
|
continue
|
|
}
|
|
channels, err := a.list("community_channels", Doc{"communityId": stringValue(community["id"])}, Sort{{Key: "createdAt", Value: 1}, {Key: "updatedAt", Value: -1}})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to list channels")
|
|
return
|
|
}
|
|
membership, _ := a.findCommunityMembership(stringValue(community["id"]), claims.Sub)
|
|
item := Doc{
|
|
"community": community,
|
|
"channels": channels,
|
|
"access": Doc{
|
|
"allowed": allowed,
|
|
"reason": reason,
|
|
},
|
|
}
|
|
if membership != nil {
|
|
item["membership"] = membership
|
|
}
|
|
out = append(out, item)
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
}
|
|
|
|
func (a *app) listLearnerCommunityChannels(w http.ResponseWriter, r *http.Request) {
|
|
claims, _ := r.Context().Value(userClaimsKey).(*accessClaims)
|
|
if claims == nil || strings.TrimSpace(claims.Sub) == "" {
|
|
writeError(w, http.StatusUnauthorized, "no authenticated user")
|
|
return
|
|
}
|
|
communityID := strings.TrimSpace(chi.URLParam(r, "id"))
|
|
if communityID == "" {
|
|
writeError(w, http.StatusBadRequest, "community id is required")
|
|
return
|
|
}
|
|
community, err := a.getByID("communities", communityID)
|
|
if err != nil {
|
|
if errors.Is(err, ErrNotFound) {
|
|
writeError(w, http.StatusNotFound, "community not found")
|
|
return
|
|
}
|
|
writeError(w, http.StatusInternalServerError, "failed to load community")
|
|
return
|
|
}
|
|
allowed, _, err := a.canAccessCommunityForClaims(claims, community)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to evaluate community access")
|
|
return
|
|
}
|
|
if !allowed {
|
|
writeError(w, http.StatusForbidden, "community access denied")
|
|
return
|
|
}
|
|
docs, err := a.list("community_channels", Doc{"communityId": communityID}, Sort{{Key: "createdAt", Value: 1}, {Key: "updatedAt", Value: -1}})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to list channels")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, docs)
|
|
}
|
|
|
|
func (a *app) joinLearnerCommunity(w http.ResponseWriter, r *http.Request) {
|
|
claims, _ := r.Context().Value(userClaimsKey).(*accessClaims)
|
|
if claims == nil || strings.TrimSpace(claims.Sub) == "" {
|
|
writeError(w, http.StatusUnauthorized, "no authenticated user")
|
|
return
|
|
}
|
|
communityID := strings.TrimSpace(chi.URLParam(r, "id"))
|
|
if communityID == "" {
|
|
writeError(w, http.StatusBadRequest, "community id is required")
|
|
return
|
|
}
|
|
community, err := a.getByID("communities", communityID)
|
|
if err != nil {
|
|
if errors.Is(err, ErrNotFound) {
|
|
writeError(w, http.StatusNotFound, "community not found")
|
|
return
|
|
}
|
|
writeError(w, http.StatusInternalServerError, "failed to load community")
|
|
return
|
|
}
|
|
allowed, _, err := a.canAccessCommunityForClaims(claims, community)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to evaluate community access")
|
|
return
|
|
}
|
|
if !allowed {
|
|
writeError(w, http.StatusForbidden, "community access denied")
|
|
return
|
|
}
|
|
existing, _ := a.findCommunityMembership(communityID, claims.Sub)
|
|
if existing != nil {
|
|
writeJSON(w, http.StatusOK, existing)
|
|
return
|
|
}
|
|
doc, err := prepareCommunityMembershipPayload(communityID, Doc{
|
|
"userId": claims.Sub,
|
|
"role": "member",
|
|
"status": "active",
|
|
"source": a.resolveCommunityJoinSource(claims, community),
|
|
}, false)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
doc, err = a.create("community_memberships", doc)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to join community")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, doc)
|
|
}
|
|
|
|
func (a *app) resolveCommunityJoinSource(claims *accessClaims, community Doc) string {
|
|
if hasRole(claims, "admin") {
|
|
return "admin_override"
|
|
}
|
|
requiredEntitlement, _ := asString(community["requiredEntitlementKey"])
|
|
if strings.TrimSpace(requiredEntitlement) != "" {
|
|
return "entitlement"
|
|
}
|
|
roles := communityAllowedRoles(community)
|
|
for _, role := range roles {
|
|
if hasRole(claims, role) {
|
|
return "role"
|
|
}
|
|
}
|
|
return "self_join"
|
|
}
|
|
|
|
func (a *app) canAccessCommunityForClaims(claims *accessClaims, community Doc) (bool, string, error) {
|
|
if claims == nil || strings.TrimSpace(claims.Sub) == "" {
|
|
return false, "missing_user", nil
|
|
}
|
|
if hasRole(claims, "admin") {
|
|
return true, "admin", nil
|
|
}
|
|
status, _ := asString(community["status"])
|
|
if status == communityStatusArchived {
|
|
return false, "archived", nil
|
|
}
|
|
visibility, _ := asString(community["visibility"])
|
|
if strings.TrimSpace(visibility) == "" {
|
|
visibility = communityVisibilityPrivate
|
|
}
|
|
if visibility == communityVisibilityPublic {
|
|
return true, "public", nil
|
|
}
|
|
if membership, _ := a.findCommunityMembership(stringValue(community["id"]), claims.Sub); membership != nil {
|
|
if strings.EqualFold(stringValue(membership["status"]), "active") {
|
|
return true, "membership", nil
|
|
}
|
|
}
|
|
for _, role := range communityAllowedRoles(community) {
|
|
if hasRole(claims, role) {
|
|
return true, "role", nil
|
|
}
|
|
}
|
|
requiredEntitlement, _ := asString(community["requiredEntitlementKey"])
|
|
requiredEntitlement = strings.TrimSpace(requiredEntitlement)
|
|
if requiredEntitlement != "" {
|
|
allowed, err := a.hasActiveEntitlement(claims.Sub, requiredEntitlement, time.Now().UTC())
|
|
if err != nil {
|
|
return false, "entitlement_error", err
|
|
}
|
|
if allowed {
|
|
return true, "entitlement", nil
|
|
}
|
|
}
|
|
accessPolicy, _ := asString(community["accessPolicy"])
|
|
if accessPolicy == communityAccessOpen {
|
|
return true, "open", nil
|
|
}
|
|
return false, "membership_required", nil
|
|
}
|
|
|
|
func communityAllowedRoles(community Doc) []string {
|
|
raw, exists := community["allowedRoles"]
|
|
if !exists || raw == nil {
|
|
return []string{}
|
|
}
|
|
values, _, err := normalizeStringSlice(raw)
|
|
if err != nil {
|
|
return []string{}
|
|
}
|
|
out := make([]string, 0, len(values))
|
|
for _, value := range values {
|
|
if strings.TrimSpace(value) == "" {
|
|
continue
|
|
}
|
|
out = append(out, strings.ToLower(strings.TrimSpace(value)))
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (a *app) findCommunityMembership(communityID, userID string) (Doc, error) {
|
|
docs, err := a.list("community_memberships", Doc{"communityId": strings.TrimSpace(communityID), "userId": strings.TrimSpace(userID)}, Sort{{Key: "updatedAt", Value: -1}, {Key: "createdAt", Value: -1}})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(docs) == 0 {
|
|
return nil, nil
|
|
}
|
|
return docs[0], nil
|
|
}
|
|
|
|
func (a *app) getCommunityChannelByID(communityID, channelID string) (Doc, error) {
|
|
channel, err := a.getByID("community_channels", strings.TrimSpace(channelID))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if strings.TrimSpace(stringValue(channel["communityId"])) != strings.TrimSpace(communityID) {
|
|
return nil, ErrNotFound
|
|
}
|
|
return channel, nil
|
|
}
|
|
|
|
func (a *app) getCommunityMembershipByID(communityID, membershipID string) (Doc, error) {
|
|
membership, err := a.getByID("community_memberships", strings.TrimSpace(membershipID))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if strings.TrimSpace(stringValue(membership["communityId"])) != strings.TrimSpace(communityID) {
|
|
return nil, ErrNotFound
|
|
}
|
|
return membership, nil
|
|
}
|
|
|
|
func prepareCommunityPayload(payload Doc, partial bool) (Doc, error) {
|
|
if payload == nil {
|
|
payload = Doc{}
|
|
}
|
|
name, hasName, err := getOptionalString(payload, "name")
|
|
if err != nil {
|
|
return nil, errors.New("name must be a string")
|
|
}
|
|
if !partial && (!hasName || name == "") {
|
|
return nil, errors.New("name is required")
|
|
}
|
|
if hasName {
|
|
payload["name"] = name
|
|
}
|
|
|
|
slug, hasSlug, err := getOptionalString(payload, "slug")
|
|
if err != nil {
|
|
return nil, errors.New("slug must be a string")
|
|
}
|
|
if !hasSlug && hasName {
|
|
slug = slugify(name)
|
|
hasSlug = true
|
|
}
|
|
if hasSlug {
|
|
payload["slug"] = normalizeSlug(slug)
|
|
}
|
|
|
|
if description, has, err := getOptionalString(payload, "description"); err != nil {
|
|
return nil, errors.New("description must be a string")
|
|
} else if has {
|
|
payload["description"] = description
|
|
}
|
|
|
|
status, hasStatus, err := getOptionalString(payload, "status")
|
|
if err != nil {
|
|
return nil, errors.New("status must be a string")
|
|
}
|
|
if !partial && !hasStatus {
|
|
status = communityStatusActive
|
|
hasStatus = true
|
|
}
|
|
if hasStatus {
|
|
status = strings.ToLower(strings.TrimSpace(status))
|
|
switch status {
|
|
case communityStatusActive, communityStatusArchived:
|
|
payload["status"] = status
|
|
default:
|
|
return nil, errors.New("status must be one of: active, archived")
|
|
}
|
|
}
|
|
|
|
visibility, hasVisibility, err := getOptionalString(payload, "visibility")
|
|
if err != nil {
|
|
return nil, errors.New("visibility must be a string")
|
|
}
|
|
if !partial && !hasVisibility {
|
|
visibility = communityVisibilityPrivate
|
|
hasVisibility = true
|
|
}
|
|
if hasVisibility {
|
|
visibility = strings.ToLower(strings.TrimSpace(visibility))
|
|
switch visibility {
|
|
case communityVisibilityPublic, communityVisibilityPrivate:
|
|
payload["visibility"] = visibility
|
|
default:
|
|
return nil, errors.New("visibility must be one of: public, private")
|
|
}
|
|
}
|
|
|
|
accessPolicy, hasPolicy, err := getOptionalString(payload, "accessPolicy")
|
|
if err != nil {
|
|
return nil, errors.New("accessPolicy must be a string")
|
|
}
|
|
if !partial && !hasPolicy {
|
|
accessPolicy = communityAccessMembership
|
|
hasPolicy = true
|
|
}
|
|
if hasPolicy {
|
|
accessPolicy = strings.ToLower(strings.TrimSpace(accessPolicy))
|
|
switch accessPolicy {
|
|
case communityAccessOpen, communityAccessRole, communityAccessEntitlement, communityAccessMembership:
|
|
payload["accessPolicy"] = accessPolicy
|
|
default:
|
|
return nil, errors.New("accessPolicy must be one of: open, role, entitlement, membership")
|
|
}
|
|
}
|
|
|
|
if allowedRoles, has, err := normalizeStringSlice(payload["allowedRoles"]); err != nil {
|
|
return nil, errors.New("allowedRoles must be an array of strings")
|
|
} else if has {
|
|
roles := make([]string, 0, len(allowedRoles))
|
|
for _, role := range allowedRoles {
|
|
normalized := strings.ToLower(strings.TrimSpace(role))
|
|
if normalized != "" {
|
|
roles = append(roles, normalized)
|
|
}
|
|
}
|
|
payload["allowedRoles"] = roles
|
|
}
|
|
|
|
requiredEntitlementKey, hasEntitlement, err := getOptionalString(payload, "requiredEntitlementKey")
|
|
if err != nil {
|
|
return nil, errors.New("requiredEntitlementKey must be a string")
|
|
}
|
|
if hasEntitlement {
|
|
payload["requiredEntitlementKey"] = requiredEntitlementKey
|
|
}
|
|
|
|
if metadata, exists := payload["metadata"]; exists && metadata != nil {
|
|
value, ok := asMap(metadata)
|
|
if !ok {
|
|
return nil, errors.New("metadata must be an object")
|
|
}
|
|
payload["metadata"] = Doc(value)
|
|
}
|
|
|
|
currentPolicy, _ := asString(payload["accessPolicy"])
|
|
if strings.TrimSpace(currentPolicy) == communityAccessRole {
|
|
roles, _, _ := normalizeStringSlice(payload["allowedRoles"])
|
|
if len(roles) == 0 {
|
|
return nil, errors.New("allowedRoles is required when accessPolicy=role")
|
|
}
|
|
}
|
|
if strings.TrimSpace(currentPolicy) == communityAccessEntitlement {
|
|
required, _ := asString(payload["requiredEntitlementKey"])
|
|
if strings.TrimSpace(required) == "" {
|
|
return nil, errors.New("requiredEntitlementKey is required when accessPolicy=entitlement")
|
|
}
|
|
}
|
|
|
|
return payload, nil
|
|
}
|
|
|
|
func prepareCommunityChannelPayload(communityID string, payload Doc, partial bool) (Doc, error) {
|
|
if payload == nil {
|
|
payload = Doc{}
|
|
}
|
|
payload["communityId"] = strings.TrimSpace(communityID)
|
|
|
|
name, hasName, err := getOptionalString(payload, "name")
|
|
if err != nil {
|
|
return nil, errors.New("name must be a string")
|
|
}
|
|
if !partial && (!hasName || name == "") {
|
|
return nil, errors.New("name is required")
|
|
}
|
|
if hasName {
|
|
payload["name"] = name
|
|
}
|
|
|
|
slug, hasSlug, err := getOptionalString(payload, "slug")
|
|
if err != nil {
|
|
return nil, errors.New("slug must be a string")
|
|
}
|
|
if !hasSlug && hasName {
|
|
slug = slugify(name)
|
|
hasSlug = true
|
|
}
|
|
if hasSlug {
|
|
payload["slug"] = normalizeSlug(slug)
|
|
}
|
|
|
|
if topic, has, err := getOptionalString(payload, "topic"); err != nil {
|
|
return nil, errors.New("topic must be a string")
|
|
} else if has {
|
|
payload["topic"] = topic
|
|
}
|
|
|
|
if isPrivate, has, err := getOptionalBool(payload, "isPrivate"); err != nil {
|
|
return nil, errors.New("isPrivate must be a boolean")
|
|
} else if has {
|
|
payload["isPrivate"] = isPrivate
|
|
} else if !partial {
|
|
payload["isPrivate"] = false
|
|
}
|
|
|
|
return payload, nil
|
|
}
|
|
|
|
func prepareCommunityMembershipPayload(communityID string, payload Doc, partial bool) (Doc, error) {
|
|
if payload == nil {
|
|
payload = Doc{}
|
|
}
|
|
payload["communityId"] = strings.TrimSpace(communityID)
|
|
|
|
userID, hasUserID, err := getOptionalString(payload, "userId")
|
|
if err != nil {
|
|
return nil, errors.New("userId must be a string")
|
|
}
|
|
if !partial && (!hasUserID || userID == "") {
|
|
return nil, errors.New("userId is required")
|
|
}
|
|
if hasUserID {
|
|
payload["userId"] = userID
|
|
}
|
|
|
|
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 {
|
|
normalized := strings.ToLower(strings.TrimSpace(status))
|
|
switch normalized {
|
|
case "active", "pending", "banned", "left":
|
|
payload["status"] = normalized
|
|
default:
|
|
return nil, errors.New("status must be one of: active, pending, banned, left")
|
|
}
|
|
}
|
|
|
|
role, hasRoleValue, err := getOptionalString(payload, "role")
|
|
if err != nil {
|
|
return nil, errors.New("role must be a string")
|
|
}
|
|
if !partial && !hasRoleValue {
|
|
role = "member"
|
|
hasRoleValue = true
|
|
}
|
|
if hasRoleValue {
|
|
normalized := strings.ToLower(strings.TrimSpace(role))
|
|
switch normalized {
|
|
case "member", "moderator", "owner":
|
|
payload["role"] = normalized
|
|
default:
|
|
return nil, errors.New("role must be one of: member, moderator, owner")
|
|
}
|
|
}
|
|
|
|
source, hasSource, err := getOptionalString(payload, "source")
|
|
if err != nil {
|
|
return nil, errors.New("source must be a string")
|
|
}
|
|
if !partial && !hasSource {
|
|
source = "manual"
|
|
hasSource = true
|
|
}
|
|
if hasSource {
|
|
normalized := strings.ToLower(strings.TrimSpace(source))
|
|
switch normalized {
|
|
case "manual", "self_join", "role", "entitlement", "admin_override":
|
|
payload["source"] = normalized
|
|
default:
|
|
return nil, errors.New("source must be one of: manual, self_join, role, entitlement, admin_override")
|
|
}
|
|
}
|
|
|
|
if metadata, exists := payload["metadata"]; exists && metadata != nil {
|
|
value, ok := asMap(metadata)
|
|
if !ok {
|
|
return nil, errors.New("metadata must be an object")
|
|
}
|
|
payload["metadata"] = Doc(value)
|
|
}
|
|
|
|
return payload, nil
|
|
}
|