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
}