A community based topic aggregation platform built on atproto

feat(community): align list endpoint to lexicon spec

Align social.coves.community.list handler to lexicon specification
following atProto standards.

**Changes:**
- Add visibility parameter (public/unlisted/private) to lexicon
- Implement sort enum mapping (popular→subscriber_count,
active→post_count, new→created_at, alphabetical→name)
- Add input validation for sort and visibility parameters
- Enforce limit bounds (1-100, default 50)
- Update ListCommunitiesRequest struct with new parameters
- Remove deprecated hostedBy parameter

**atProto Compliance:**
- Use string cursor type (not int)
- Remove undocumented "total" field (follows Bluesky patterns)
- Eliminate COUNT query for better performance
- Return empty cursor when pagination complete

**Performance:**
- Single query instead of COUNT + SELECT
- Proper cursor-based pagination

**Code Quality:**
- Fix magic number in GetDisplayHandle (11 → len(".community."))
- Add TODO comments for future category/language filters

Addresses lexicon contract violations and follows atProto design
patterns from bluesky-social/atproto#4245.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

Changed files
+105 -49
internal
api
handlers
community
atproto
lexicon
social
coves
community
core
db
+56 -10
internal/api/handlers/community/list.go
···
}
// HandleList lists communities with filters
-
// GET /xrpc/social.coves.community.list?limit={n}&cursor={offset}&visibility={public|unlisted}&sortBy={created_at|member_count}
+
// GET /xrpc/social.coves.community.list?limit={n}&cursor={str}&sort={popular|active|new|alphabetical}&visibility={public|unlisted|private}
func (h *ListHandler) HandleList(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
···
// Parse query parameters
query := r.URL.Query()
+
// Parse limit (1-100, default 50)
limit := 50
if limitStr := query.Get("limit"); limitStr != "" {
-
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
-
limit = l
+
if l, err := strconv.Atoi(limitStr); err == nil {
+
if l < 1 {
+
limit = 1
+
} else if l > 100 {
+
limit = 100
+
} else {
+
limit = l
+
}
}
}
+
// Parse cursor (offset-based for now)
offset := 0
if cursorStr := query.Get("cursor"); cursorStr != "" {
if o, err := strconv.Atoi(cursorStr); err == nil && o >= 0 {
···
}
}
+
// Parse sort enum (default: popular)
+
sort := query.Get("sort")
+
if sort == "" {
+
sort = "popular"
+
}
+
+
// Validate sort value
+
validSorts := map[string]bool{
+
"popular": true,
+
"active": true,
+
"new": true,
+
"alphabetical": true,
+
}
+
if !validSorts[sort] {
+
http.Error(w, "Invalid sort value. Must be: popular, active, new, or alphabetical", http.StatusBadRequest)
+
return
+
}
+
+
// Validate visibility value if provided
+
visibility := query.Get("visibility")
+
if visibility != "" {
+
validVisibilities := map[string]bool{
+
"public": true,
+
"unlisted": true,
+
"private": true,
+
}
+
if !validVisibilities[visibility] {
+
http.Error(w, "Invalid visibility value. Must be: public, unlisted, or private", http.StatusBadRequest)
+
return
+
}
+
}
+
req := communities.ListCommunitiesRequest{
Limit: limit,
Offset: offset,
-
Visibility: query.Get("visibility"),
-
HostedBy: query.Get("hostedBy"),
-
SortBy: query.Get("sortBy"),
-
SortOrder: query.Get("sortOrder"),
+
Sort: sort,
+
Visibility: visibility,
+
Category: query.Get("category"),
+
Language: query.Get("language"),
}
// Get communities from AppView DB
-
results, total, err := h.service.ListCommunities(r.Context(), req)
+
results, err := h.service.ListCommunities(r.Context(), req)
if err != nil {
handleServiceError(w, err)
return
}
// Build response
+
var cursor string
+
if len(results) == limit {
+
// More results available - return next cursor
+
cursor = strconv.Itoa(offset + len(results))
+
}
+
// If len(results) < limit, we've reached the end - cursor remains empty string
+
response := map[string]interface{}{
"communities": results,
-
"cursor": offset + len(results),
-
"total": total,
+
"cursor": cursor,
}
w.Header().Set("Content-Type", "application/json")
+5
internal/atproto/lexicon/social/coves/community/list.json
···
"type": "string",
"description": "Pagination cursor"
},
+
"visibility": {
+
"type": "string",
+
"knownValues": ["public", "unlisted", "private"],
+
"description": "Filter communities by visibility level"
+
},
"sort": {
"type": "string",
"knownValues": ["popular", "active", "new", "alphabetical"],
+8 -8
internal/core/communities/community.go
···
// ListCommunitiesRequest represents query parameters for listing communities
type ListCommunitiesRequest struct {
-
Visibility string `json:"visibility,omitempty"`
-
HostedBy string `json:"hostedBy,omitempty"`
-
SortBy string `json:"sortBy,omitempty"`
-
SortOrder string `json:"sortOrder,omitempty"`
-
Limit int `json:"limit"`
-
Offset int `json:"offset"`
+
Sort string `json:"sort,omitempty"` // Enum: popular, active, new, alphabetical
+
Visibility string `json:"visibility,omitempty"` // Filter: public, unlisted, private
+
Category string `json:"category,omitempty"` // Optional: filter by category (future)
+
Language string `json:"language,omitempty"` // Optional: filter by language (future)
+
Limit int `json:"limit"` // 1-100, default 50
+
Offset int `json:"offset"` // Pagination offset
}
// SearchCommunitiesRequest represents query parameters for searching communities
···
name := c.Handle[:communityIndex]
// Extract instance domain (everything after ".community.")
-
// len(".community.") = 11
-
instanceDomain := c.Handle[communityIndex+11:]
+
communitySegment := ".community."
+
instanceDomain := c.Handle[communityIndex+len(communitySegment):]
return fmt.Sprintf("!%s@%s", name, instanceDomain)
}
+2 -2
internal/core/communities/interfaces.go
···
UpdateCredentials(ctx context.Context, did, accessToken, refreshToken string) error
// Listing & Search
-
List(ctx context.Context, req ListCommunitiesRequest) ([]*Community, int, error) // Returns communities + total count
+
List(ctx context.Context, req ListCommunitiesRequest) ([]*Community, error)
Search(ctx context.Context, req SearchCommunitiesRequest) ([]*Community, int, error)
// Subscriptions (lightweight feed follows)
···
CreateCommunity(ctx context.Context, req CreateCommunityRequest) (*Community, error)
GetCommunity(ctx context.Context, identifier string) (*Community, error) // identifier can be DID or handle
UpdateCommunity(ctx context.Context, req UpdateCommunityRequest) (*Community, error)
-
ListCommunities(ctx context.Context, req ListCommunitiesRequest) ([]*Community, int, error)
+
ListCommunities(ctx context.Context, req ListCommunitiesRequest) ([]*Community, error)
SearchCommunities(ctx context.Context, req SearchCommunitiesRequest) ([]*Community, int, error)
// Subscription operations (write-forward: creates record in user's PDS)
+1 -1
internal/core/communities/service.go
···
}
// ListCommunities queries AppView DB for communities with filters
-
func (s *communityService) ListCommunities(ctx context.Context, req ListCommunitiesRequest) ([]*Community, int, error) {
+
func (s *communityService) ListCommunities(ctx context.Context, req ListCommunitiesRequest) ([]*Community, error) {
// Set defaults
if req.Limit <= 0 || req.Limit > 100 {
req.Limit = 50
+33 -28
internal/db/postgres/community_repo.go
···
}
// List retrieves communities with filtering and pagination
-
func (r *postgresCommunityRepo) List(ctx context.Context, req communities.ListCommunitiesRequest) ([]*communities.Community, int, error) {
+
func (r *postgresCommunityRepo) List(ctx context.Context, req communities.ListCommunitiesRequest) ([]*communities.Community, error) {
// Build query with filters
whereClauses := []string{}
args := []interface{}{}
···
argCount++
}
-
if req.HostedBy != "" {
-
whereClauses = append(whereClauses, fmt.Sprintf("hosted_by_did = $%d", argCount))
-
args = append(args, req.HostedBy)
-
argCount++
-
}
+
// TODO: Add category filter when DB schema supports it
+
// if req.Category != "" { ... }
+
+
// TODO: Add language filter when DB schema supports it
+
// if req.Language != "" { ... }
whereClause := ""
if len(whereClauses) > 0 {
whereClause = "WHERE " + strings.Join(whereClauses, " AND ")
}
-
// Get total count
-
countQuery := fmt.Sprintf("SELECT COUNT(*) FROM communities %s", whereClause)
-
var totalCount int
-
err := r.db.QueryRowContext(ctx, countQuery, args...).Scan(&totalCount)
-
if err != nil {
-
return nil, 0, fmt.Errorf("failed to count communities: %w", err)
-
}
+
// Build sort clause - map sort enum to DB columns
+
sortColumn := "subscriber_count" // default: popular
+
sortOrder := "DESC"
-
// Build sort clause
-
sortColumn := "created_at"
-
if req.SortBy != "" {
-
switch req.SortBy {
-
case "member_count", "subscriber_count", "post_count", "created_at":
-
sortColumn = req.SortBy
-
}
-
}
-
-
sortOrder := "DESC"
-
if strings.ToUpper(req.SortOrder) == "ASC" {
+
switch req.Sort {
+
case "popular":
+
// Most subscribers (default)
+
sortColumn = "subscriber_count"
+
sortOrder = "DESC"
+
case "active":
+
// Most posts/activity
+
sortColumn = "post_count"
+
sortOrder = "DESC"
+
case "new":
+
// Recently created
+
sortColumn = "created_at"
+
sortOrder = "DESC"
+
case "alphabetical":
+
// Sorted by name A-Z
+
sortColumn = "name"
sortOrder = "ASC"
+
default:
+
// Fallback to popular if empty or invalid (should be validated in handler)
+
sortColumn = "subscriber_count"
+
sortOrder = "DESC"
}
// Get communities with pagination
···
rows, err := r.db.QueryContext(ctx, query, args...)
if err != nil {
-
return nil, 0, fmt.Errorf("failed to list communities: %w", err)
+
return nil, fmt.Errorf("failed to list communities: %w", err)
}
defer func() {
if closeErr := rows.Close(); closeErr != nil {
···
&recordURI, &recordCID,
)
if scanErr != nil {
-
return nil, 0, fmt.Errorf("failed to scan community: %w", scanErr)
+
return nil, fmt.Errorf("failed to scan community: %w", scanErr)
}
// Map nullable fields
···
}
if err = rows.Err(); err != nil {
-
return nil, 0, fmt.Errorf("error iterating communities: %w", err)
+
return nil, fmt.Errorf("error iterating communities: %w", err)
}
-
return result, totalCount, nil
+
return result, nil
}
// Search searches communities by name/description using fuzzy matching