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 }