diff --git a/docs/PRD_AUTHOR_OWNED_POSTS.md b/docs/PRD_AUTHOR_OWNED_POSTS.md index 0a3dcbf..a230fa0 100644 --- a/docs/PRD_AUTHOR_OWNED_POSTS.md +++ b/docs/PRD_AUTHOR_OWNED_POSTS.md @@ -39,6 +39,17 @@ explicitly deferred to Beta. Rev 2.6 (2026-08-08): task-3 second-opinion — fingerprint normalized to resolved-DID scope, release decoupled from request context, admission wiring fail-loud, ActorClass fail-closed. +Rev 2.8 (2026-08-08): task-7 read-path inventory — the visibility predicate +is a shared query-builder JOIN (not a SQL view: feeds carry a computed +hot_rank column + a cursor subquery reading posts directly, and author-self- +view is viewer-DID-parameterized). Per-row join key (a.community_did = +p.community_did AND a.post_uri = p.uri) so cross-community feeds resolve each +post against ITS community's decision (fork case). INNER→LEFT users join +(unknown-author visibility) pairs mandatorily with scanPostView null-handling +(handle COALESCE to author_did, PDSURL, blobOwnerOf). getComments is a +separate path (raw GetByURI, no view, actively serves soft-deleted — own +cycle). Community post_count has NO incrementer — consumer/admission-driven, +not a read predicate. Post text search does not exist (negative guard only). Rev 2.7 (2026-08-08): task-5 plan review — post.getStatus pulled forward into task 5 as the T2 observation surface (unauthenticated; mild disclosure of rejected-post status accepted, owner-flagged); hosted-community detection = diff --git a/internal/atproto/lexicon/social/coves/community/post/defs.json b/internal/atproto/lexicon/social/coves/community/post/defs.json index 5d21814..0a1ea49 100644 --- a/internal/atproto/lexicon/social/coves/community/post/defs.json +++ b/internal/atproto/lexicon/social/coves/community/post/defs.json @@ -72,6 +72,23 @@ "viewer": { "type": "ref", "ref": "#viewerState" + }, + "status": { + "type": "string", + "knownValues": [ + "pending", + "accepted", + "pending_reacceptance", + "rejected", + "removed" + ], + "description": "This post's per-community admission status. Present on an accepted post, and on an author's own view of their non-accepted posts; omitted from a public postView of a legacy/bridged row. Optional.", + "maxLength": 64 + }, + "acceptanceUri": { + "type": "string", + "format": "at-uri", + "description": "AT-URI of the community acceptance record that admitted this post. Present only while an acceptance stands. Optional." } } }, @@ -149,6 +166,29 @@ } } }, + "removedPost": { + "type": "object", + "description": "Post was removed by its own community's moderators. Served as a tombstone carrying the removal code — distinct from notFoundPost (which is indistinguishable from 'never existed') so a client can explain the takedown to the author.", + "required": [ + "uri", + "removed" + ], + "properties": { + "uri": { + "type": "string", + "format": "at-uri" + }, + "removed": { + "type": "boolean", + "const": true + }, + "code": { + "type": "string", + "description": "The community's removal decision code (e.g. rule-violation, spam, off-topic).", + "maxLength": 64 + } + } + }, "blockedPost": { "type": "object", "description": "Post is blocked due to viewer blocking author/community, or community moderation", diff --git a/internal/atproto/lexicon/social/coves/community/post/get.json b/internal/atproto/lexicon/social/coves/community/post/get.json index 47ed913..1b0b44d 100644 --- a/internal/atproto/lexicon/social/coves/community/post/get.json +++ b/internal/atproto/lexicon/social/coves/community/post/get.json @@ -35,6 +35,7 @@ "refs": [ "social.coves.community.post.defs#postView", "social.coves.community.post.defs#notFoundPost", + "social.coves.community.post.defs#removedPost", "social.coves.community.post.defs#blockedPost" ] } diff --git a/internal/core/posts/post.go b/internal/core/posts/post.go index 352f08a..8025d4c 100644 --- a/internal/core/posts/post.go +++ b/internal/core/posts/post.go @@ -169,14 +169,29 @@ type BlockedPost struct { Author *BlockedAuthor `json:"author,omitempty"` } +// RemovedPost is a union member of the social.coves.community.post.get output, +// emitted when a found post has been REMOVED by its own community. It mirrors +// notFoundPost (uri + a const discriminator) and additionally carries the removal +// `code`, so a client can render "removed by moderators: rule-violation" rather +// than the blank permalink a notFoundPost would produce. A removed post is a +// tombstone the author is owed the reason for, not a post that never existed. +// Matches social.coves.community.post.defs#removedPost. +type RemovedPost struct { + URI string `json:"uri"` + Removed bool `json:"removed"` // Always true (const per lexicon); discriminates the union on the wire + Code string `json:"code,omitempty"` +} + // PostResult is one ordered element of a GetPosts response. Exactly one of Post, -// Blocked, or NotFound is set: Post when the post was found and visible to the viewer, -// Blocked when the viewer has blocked the author, NotFound when the URI could not be -// resolved. Construct results via the result helpers so the const discriminators -// (notFound/blocked == true) cannot be left unset. +// Blocked, Removed, or NotFound is set: Post when the post was found and visible +// to the viewer, Blocked when the viewer has blocked the author, Removed when the +// post's own community removed it, NotFound when the URI could not be resolved. +// Construct results via the result helpers so the const discriminators +// (notFound/blocked/removed == true) cannot be left unset. type PostResult struct { Post *PostView Blocked *BlockedPost + Removed *RemovedPost NotFound *NotFoundPost } @@ -190,6 +205,12 @@ func notFoundResult(uri string) *PostResult { return &PostResult{NotFound: &NotFoundPost{URI: uri, NotFound: true}} } +// removedResult builds a removedPost union member with its const discriminator set, +// carrying the community's removal code so the client can explain the takedown. +func removedResult(uri, code string) *PostResult { + return &PostResult{Removed: &RemovedPost{URI: uri, Removed: true, Code: code}} +} + // blockedByAuthorResult builds a blockedPost union member (blockedBy "author") with its // const discriminator set. func blockedByAuthorResult(uri, authorDID string) *PostResult { @@ -227,6 +248,10 @@ func (r *PostResult) Member() (interface{}, bool) { member = r.Blocked count++ } + if r.Removed != nil { + member = r.Removed + count++ + } if r.NotFound != nil { member = r.NotFound count++ @@ -271,10 +296,19 @@ type PostView struct { RKey string `json:"rkey"` CID string `json:"cid"` URI string `json:"uri"` - UpvoteCount int `json:"-"` - DownvoteCount int `json:"-"` - Score int `json:"-"` - CommentCount int `json:"-"` + + // Status and AcceptanceURI are the per-community admission context (PRD §6.2), + // populated from the visibility join. Both are additive-optional: a public + // postView carries status "accepted" (and the acceptance URI), while an + // author's own non-accepted post carries "pending"/"removed"/etc.; a + // legacy/bridged row that holds no admission omits them entirely. + Status string `json:"status,omitempty"` + AcceptanceURI string `json:"acceptanceUri,omitempty"` + + UpvoteCount int `json:"-"` + DownvoteCount int `json:"-"` + Score int `json:"-"` + CommentCount int `json:"-"` } // AuthorView represents author information in post views diff --git a/internal/core/posts/service.go b/internal/core/posts/service.go index a586653..62f6f9d 100644 --- a/internal/core/posts/service.go +++ b/internal/core/posts/service.go @@ -1155,13 +1155,24 @@ func (s *postService) GetPosts(ctx context.Context, req GetPostsRequest) ([]*Pos return nil, fmt.Errorf("failed to fetch post views: %w", err) } - // 3. Assemble results in request order; valid-but-absent URIs become notFoundPost + // 3. Assemble results in request order. A visible view is a postView; an + // absent URI is a notFoundPost — UNLESS its own community removed it, in + // which case it becomes a #removedPost tombstone carrying the removal code + // (PRD §3.4/§6.2). The visibility predicate hides a removed post from + // GetViewsByURIs exactly as it hides a pending one, so the removal is + // recovered here from the admission row rather than from the (absent) view. + removed := s.removedMarkers(ctx, req.URIs, views) results := make([]*PostResult, len(req.URIs)) for i, uri := range req.URIs { - if view := views[uri]; view != nil { - results[i] = foundResult(view) - } else { - results[i] = notFoundResult(uri) + switch { + case views[uri] != nil: + results[i] = foundResult(views[uri]) + default: + if code, ok := removed[uri]; ok { + results[i] = removedResult(uri, code) + } else { + results[i] = notFoundResult(uri) + } } } @@ -1177,6 +1188,52 @@ func (s *postService) GetPosts(ctx context.Context, req GetPostsRequest) ([]*Pos return results, nil } +// removedMarkers returns, for the requested URIs absent from the visible view +// set, the removal code of any whose OWN community removed it — so post.get can +// serve a #removedPost tombstone (PRD §3.4) instead of collapsing a moderator +// removal into an indistinguishable notFoundPost. The presence of a URI in the +// returned map is the removed signal; the value is the code (possibly empty). +// +// It is a no-op when the admissions store is not wired (minimal setups and unit +// tests), leaving every absent URI a plain notFound — the pre-task-7 behavior. +func (s *postService) removedMarkers(ctx context.Context, uris []string, views map[string]*PostView) map[string]string { + markers := make(map[string]string) + if s.admissions == nil { + return markers + } + + seen := make(map[string]struct{}, len(uris)) + for _, uri := range uris { + if views[uri] != nil { + continue // visible — not a candidate for a tombstone + } + if _, done := seen[uri]; done { + continue + } + seen[uri] = struct{}{} + + // A removal is an admission-state change, not a soft delete, so the post + // row still stands and its own community — the key the admission is + // scoped by — comes straight off it. A URI with no row is genuinely + // not-indexed and stays a notFound. + post, err := s.repo.GetByURI(ctx, uri) + if err != nil { + continue + } + admission, err := s.admissions.Get(ctx, post.CommunityDID, uri) + if err != nil || admission == nil || admission.Status != AdmissionStatusRemoved { + continue + } + + code := "" + if admission.DecisionCode != nil { + code = *admission.DecisionCode + } + markers[uri] = code + } + return markers +} + // applyViewerBlocks rewrites found posts whose author the viewer has blocked into // blockedPost results (blockedBy "author"). It batches the block lookup over the unique // author DIDs in the result set. On lookup failure it returns an error rather than diff --git a/internal/db/postgres/discover_repo.go b/internal/db/postgres/discover_repo.go index bb47754..072d40e 100644 --- a/internal/db/postgres/discover_repo.go +++ b/internal/db/postgres/discover_repo.go @@ -44,32 +44,41 @@ func (r *postgresDiscoverRepo) GetDiscover(ctx context.Context, req discover.Get selectClause = feedPostSelectClause("NULL::numeric") } - // Build optional viewer block filter (only when authenticated viewer is present) + // The admission visibility gate always runs. Discover spans every community, + // so it cannot lean on a community filter — the gate keys the admission row on + // (a.community_did = p.community_did AND a.post_uri = p.uri) so a post is + // visible iff ITS OWN community accepted it, never a community that forked it. + // Its viewer parameter ($visibilityParam) is empty for the anonymous public, + // which sees accepted content only. + visibilityParam := 2 + len(cursorValues) + visJoin, visWhere := visiblePostsJoin(visibilityParam) + + // The viewer block filter reuses that same viewer parameter; only meaningful + // for an authenticated viewer, absent for the public. var viewerFilter string - var viewerArgs []interface{} if req.ViewerDID != "" { - viewerParamIdx := 2 + len(cursorValues) - viewerFilter = fmt.Sprintf("AND NOT EXISTS (SELECT 1 FROM user_blocks WHERE blocker_did = $%d AND blocked_did = p.author_did)", viewerParamIdx) - viewerArgs = append(viewerArgs, req.ViewerDID) + viewerFilter = fmt.Sprintf("AND NOT EXISTS (SELECT 1 FROM user_blocks WHERE blocker_did = $%d AND blocked_did = p.author_did)", visibilityParam) } // No subscription filter - show ALL posts from ALL communities query := fmt.Sprintf(` %s - INNER JOIN users u ON p.author_did = u.did - INNER JOIN communities c ON p.community_did = c.did + LEFT JOIN users u ON p.author_did = u.did + INNER JOIN communities c ON p.community_did = c.did%s WHERE p.deleted_at IS NULL + AND %s %s %s %s ORDER BY %s LIMIT $1 - `, selectClause, timeFilter, cursorFilter, viewerFilter, orderBy) + `, selectClause, visJoin, visWhere, timeFilter, cursorFilter, viewerFilter, orderBy) - // Prepare query arguments + // Prepare query arguments. The viewer DID is bound once at $visibilityParam + // and reused by the block filter above. args := []interface{}{req.Limit + 1} // +1 to check for next page args = append(args, cursorValues...) - args = append(args, viewerArgs...) + args = append(args, req.ViewerDID) // Execute query rows, err := r.db.QueryContext(ctx, query, args...) diff --git a/internal/db/postgres/feed_repo.go b/internal/db/postgres/feed_repo.go index 1c4f0f9..df239cb 100644 --- a/internal/db/postgres/feed_repo.go +++ b/internal/db/postgres/feed_repo.go @@ -46,32 +46,39 @@ func (r *postgresFeedRepo) GetCommunityFeed(ctx context.Context, req communityFe selectClause = feedPostSelectClause("NULL::numeric") } - // Build optional viewer block filter (only when authenticated viewer is present) + // The admission visibility gate always runs, so no read path can serve a + // post its community has not admitted. Its viewer parameter ($visibilityParam) + // carries the read's viewer DID — empty for the anonymous public, which sees + // accepted content only. + visibilityParam := 3 + len(cursorValues) + visJoin, visWhere := visiblePostsJoin(visibilityParam) + + // The viewer block filter reuses that same viewer parameter; it is only + // meaningful for an authenticated viewer and absent for the public. var viewerFilter string - var viewerArgs []interface{} if req.ViewerDID != "" { - viewerParamIdx := 3 + len(cursorValues) - viewerFilter = fmt.Sprintf("AND NOT EXISTS (SELECT 1 FROM user_blocks WHERE blocker_did = $%d AND blocked_did = p.author_did)", viewerParamIdx) - viewerArgs = append(viewerArgs, req.ViewerDID) + viewerFilter = fmt.Sprintf("AND NOT EXISTS (SELECT 1 FROM user_blocks WHERE blocker_did = $%d AND blocked_did = p.author_did)", visibilityParam) } query := fmt.Sprintf(` %s - INNER JOIN users u ON p.author_did = u.did - INNER JOIN communities c ON p.community_did = c.did + LEFT JOIN users u ON p.author_did = u.did + INNER JOIN communities c ON p.community_did = c.did%s WHERE p.community_did = $1 AND p.deleted_at IS NULL + AND %s %s %s %s ORDER BY %s LIMIT $2 - `, selectClause, timeFilter, cursorFilter, viewerFilter, orderBy) + `, selectClause, visJoin, visWhere, timeFilter, cursorFilter, viewerFilter, orderBy) - // Prepare query arguments + // Prepare query arguments. The viewer DID is bound once at $visibilityParam + // and reused by the block filter above. args := []interface{}{req.Community, req.Limit + 1} // +1 to check for next page args = append(args, cursorValues...) - args = append(args, viewerArgs...) + args = append(args, req.ViewerDID) // Execute query rows, err := r.db.QueryContext(ctx, query, args...) diff --git a/internal/db/postgres/post_repo.go b/internal/db/postgres/post_repo.go index a41444e..cd27e06 100644 --- a/internal/db/postgres/post_repo.go +++ b/internal/db/postgres/post_repo.go @@ -37,13 +37,22 @@ type postgresPostRepo struct { // (upvote_count + bridged_upvote_count, etc.) so federated/bridged content shows the // origin platform's votes. score is already stored inclusive of bridged aggregates, so // it is selected as-is. +// +// author_handle is COALESCEd to the author DID because the users join is a LEFT +// join (a federated author with no users row must not vanish, PRD §5.3), so +// u.handle is NULL for an unindexed author; the comment read path does the same +// (comment_repo.go). a.status and a.acceptance_uri come from the admission LEFT +// join every display query splices in via visiblePostsJoin — they carry the +// per-community admission context an author's own view renders, and are NULL for +// legacy/bridged rows that hold no admission. const postViewSelectColumns = ` p.uri, p.cid, p.rkey, - p.author_did, u.handle as author_handle, u.display_name as author_display_name, u.avatar_cid as author_avatar, u.pds_url as author_pds_url, + p.author_did, COALESCE(u.handle, p.author_did) as author_handle, u.display_name as author_display_name, u.avatar_cid as author_avatar, u.pds_url as author_pds_url, p.community_did, c.handle as community_handle, c.name as community_name, c.avatar_cid as community_avatar, c.pds_url as community_pds_url, p.title, p.content, p.content_facets, p.embed, p.content_labels, p.created_at, p.edited_at, p.indexed_at, - p.upvote_count + p.bridged_upvote_count AS upvote_count, p.downvote_count + p.bridged_downvote_count AS downvote_count, p.score, p.comment_count` + p.upvote_count + p.bridged_upvote_count AS upvote_count, p.downvote_count + p.bridged_downvote_count AS downvote_count, p.score, p.comment_count, + a.status AS admission_status, a.acceptance_uri AS admission_acceptance_uri` // NewPostRepository creates a new PostgreSQL post repository func NewPostRepository(db *sql.DB) posts.Repository { @@ -181,18 +190,26 @@ func (r *postgresPostRepo) GetViewsByURIs(ctx context.Context, uris []string) (m return result, nil } - // Static query: the URI set is bound through a single array parameter (= ANY($1)) - // rather than an interpolated IN list, so the SQL is constant (no fmt.Sprintf, one - // cached query plan regardless of batch size) and the values stay fully parameterized. + // The URI set is bound through a single array parameter (= ANY($1)) rather + // than an interpolated IN list, so the SQL stays fully parameterized and the + // plan is cached regardless of batch size. + // + // post.get is the public permalink surface, so the visibility gate runs with + // an ANONYMOUS viewer ($2 = ""): accepted posts (and legacy/bridged rows) + // only. A pending/rejected/removed post is absent from the result, which the + // service renders as notFoundPost — or, for a removal, upgrades to a + // #removedPost tombstone from the admission row. An author's privileged view + // of their own pending posts is served by actor.getPosts (GetByAuthor), which + // threads a real viewer DID. + visJoin, visWhere := visiblePostsJoin(2) query := ` SELECT` + postViewSelectColumns + ` FROM posts p - INNER JOIN users u ON p.author_did = u.did - INNER JOIN communities c ON p.community_did = c.did - WHERE p.uri = ANY($1) AND p.deleted_at IS NULL - ` + LEFT JOIN users u ON p.author_did = u.did + INNER JOIN communities c ON p.community_did = c.did` + visJoin + ` + WHERE p.uri = ANY($1) AND p.deleted_at IS NULL AND ` + visWhere - rows, err := r.db.QueryContext(ctx, query, pq.Array(uris)) + rows, err := r.db.QueryContext(ctx, query, pq.Array(uris), "") if err != nil { return nil, fmt.Errorf("failed to query posts by URIs: %w", err) } @@ -263,6 +280,19 @@ func (r *postgresPostRepo) GetByAuthor(ctx context.Context, req posts.GetAuthorP paramIndex += len(cursorArgs) } + // The admission visibility gate, threading the viewer DID. A stranger (or the + // anonymous public) sees the author's accepted posts only; the author + // themselves ($viewer = ActorDID) additionally sees their own pending / + // rejected / removed posts, which is how a profile renders per-community + // status (PRD §6.2). This is the alternate-endpoint the feed gate is + // worthless without: an author feed showing ungated content leaks exactly + // what the community feed hides. + visibilityParam := paramIndex + visJoin, visWhere := visiblePostsJoin(visibilityParam) + whereConditions = append(whereConditions, visWhere) + args = append(args, req.ViewerDID) + paramIndex++ + // Add limit to args limit := req.Limit if limit <= 0 { @@ -278,12 +308,12 @@ func (r *postgresPostRepo) GetByAuthor(ctx context.Context, req posts.GetAuthorP query := fmt.Sprintf(` SELECT %s FROM posts p - INNER JOIN users u ON p.author_did = u.did - INNER JOIN communities c ON p.community_did = c.did + LEFT JOIN users u ON p.author_did = u.did + INNER JOIN communities c ON p.community_did = c.did%s WHERE %s ORDER BY p.created_at DESC, p.uri DESC LIMIT $%d - `, postViewSelectColumns, whereClause, paramIndex) + `, postViewSelectColumns, visJoin, whereClause, paramIndex) // Execute query rows, err := r.db.QueryContext(ctx, query, args...) @@ -413,6 +443,8 @@ func scanPostView(rows *sql.Rows, extraDest ...interface{}) (*posts.PostView, er communityHandle sql.NullString communityAvatar sql.NullString communityPDSURL sql.NullString + admissionStatus sql.NullString + acceptanceURI sql.NullString ) dest := []interface{}{ @@ -422,6 +454,7 @@ func scanPostView(rows *sql.Rows, extraDest ...interface{}) (*posts.PostView, er &title, &content, &facets, &embed, &labelsJSON, &postView.CreatedAt, &editedAt, &postView.IndexedAt, &postView.UpvoteCount, &postView.DownvoteCount, &postView.Score, &postView.CommentCount, + &admissionStatus, &acceptanceURI, } dest = append(dest, extraDest...) @@ -462,6 +495,17 @@ func scanPostView(rows *sql.Rows, extraDest ...interface{}) (*posts.PostView, er postView.EditedAt = &editedAt.Time } + // Per-community admission context (PRD §6.2). Present on any row the + // visibility predicate returned that carries an admission decision — every + // accepted post, and an author's own non-accepted posts on their profile. + // Absent (NULL) for legacy/bridged rows, which omit it on the wire. + if admissionStatus.Valid { + postView.Status = admissionStatus.String + } + if acceptanceURI.Valid { + postView.AcceptanceURI = acceptanceURI.String + } + // Parse facets JSON into local variable (will be added to record below) // Log errors but continue - malformed optional fields shouldn't break the response var facetArray []interface{} diff --git a/internal/db/postgres/timeline_repo.go b/internal/db/postgres/timeline_repo.go index 6cb0dd3..7c3de70 100644 --- a/internal/db/postgres/timeline_repo.go +++ b/internal/db/postgres/timeline_repo.go @@ -46,21 +46,28 @@ func (r *postgresTimelineRepo) GetTimeline(ctx context.Context, req timeline.Get selectClause = feedPostSelectClause("NULL::numeric") } + // The admission visibility gate reuses $1 (the subscriber's DID) as the + // viewer — the same intentional reuse the block filter below makes — so a + // pending post reaching the home feed is impossible, while the subscriber + // still sees their own non-accepted posts in communities they subscribe to. + visJoin, visWhere := visiblePostsJoin(1) + // Join with community_subscriptions to get posts from subscribed communities query := fmt.Sprintf(` %s - INNER JOIN users u ON p.author_did = u.did + LEFT JOIN users u ON p.author_did = u.did INNER JOIN communities c ON p.community_did = c.did - INNER JOIN community_subscriptions cs ON p.community_did = cs.community_did + INNER JOIN community_subscriptions cs ON p.community_did = cs.community_did%s WHERE cs.user_did = $1 AND p.deleted_at IS NULL + AND %s -- Intentional $1 reuse: the viewer's DID (cs.user_did) is also the blocker for block filtering AND NOT EXISTS (SELECT 1 FROM user_blocks WHERE blocker_did = $1 AND blocked_did = p.author_did) %s %s ORDER BY %s LIMIT $2 - `, selectClause, timeFilter, cursorFilter, orderBy) + `, selectClause, visJoin, visWhere, timeFilter, cursorFilter, orderBy) // Prepare query arguments args := []interface{}{req.UserDID, req.Limit + 1} // +1 to check for next page diff --git a/internal/db/postgres/user_repo.go b/internal/db/postgres/user_repo.go index f65d3df..c2a8fe7 100644 --- a/internal/db/postgres/user_repo.go +++ b/internal/db/postgres/user_repo.go @@ -236,9 +236,18 @@ func (r *postgresUserRepo) GetProfileStats(ctx context.Context, did string) (*us // Reputation represents historical contributions, while membership_count // reflects current active community access. A banned user keeps their // earned reputation but loses the membership count. + // post_count counts VISIBLE posts only: a profile advertising posts no reader + // can reach is a side channel onto non-accepted content (PRD §6.2). A post + // with a decision counts only once its own community accepted it; a row with + // no admission (legacy, bridged, or an as-yet-unjudged postv2) counts as + // before. This is the public count — the anonymous accepted-or-undecided rule, + // with no author self-view branch — matching visiblePostsJoin. query := ` SELECT - (SELECT COUNT(*) FROM posts WHERE author_did = $1 AND deleted_at IS NULL) as post_count, + (SELECT COUNT(*) FROM posts p + LEFT JOIN community_post_admissions a ON a.community_did = p.community_did AND a.post_uri = p.uri + WHERE p.author_did = $1 AND p.deleted_at IS NULL + AND (a.status = 'accepted' OR a.status IS NULL)) as post_count, (SELECT COUNT(*) FROM comments WHERE commenter_did = $1 AND deleted_at IS NULL) as comment_count, (SELECT COUNT(*) FROM community_subscriptions WHERE user_did = $1) as community_count, (SELECT COUNT(*) FROM community_memberships WHERE user_did = $1 AND is_banned = false) as membership_count,