diff --git a/internal/api/handlers/community/list.go b/internal/api/handlers/community/list.go index cde230a..4972916 100644 --- a/internal/api/handlers/community/list.go +++ b/internal/api/handlers/community/list.go @@ -20,7 +20,7 @@ func NewListHandler(service communities.Service) *ListHandler { } // 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) @@ -30,13 +30,21 @@ func (h *ListHandler) HandleList(w http.ResponseWriter, r *http.Request) { // 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 { @@ -44,27 +52,65 @@ func (h *ListHandler) HandleList(w http.ResponseWriter, r *http.Request) { } } + // 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") diff --git a/internal/atproto/lexicon/social/coves/community/list.json b/internal/atproto/lexicon/social/coves/community/list.json index 0e06fb1..412b23d 100644 --- a/internal/atproto/lexicon/social/coves/community/list.json +++ b/internal/atproto/lexicon/social/coves/community/list.json @@ -18,6 +18,11 @@ "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"], diff --git a/internal/core/communities/community.go b/internal/core/communities/community.go index 0d0c2df..e745d49 100644 --- a/internal/core/communities/community.go +++ b/internal/core/communities/community.go @@ -123,12 +123,12 @@ type UpdateCommunityRequest struct { // 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 @@ -159,8 +159,8 @@ func (c *Community) GetDisplayHandle() string { 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) } diff --git a/internal/core/communities/interfaces.go b/internal/core/communities/interfaces.go index 32b127d..84b3055 100644 --- a/internal/core/communities/interfaces.go +++ b/internal/core/communities/interfaces.go @@ -16,7 +16,7 @@ type Repository interface { 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) @@ -62,7 +62,7 @@ type Service interface { 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) diff --git a/internal/core/communities/service.go b/internal/core/communities/service.go index 2d74c4f..9824df0 100644 --- a/internal/core/communities/service.go +++ b/internal/core/communities/service.go @@ -528,7 +528,7 @@ func (s *communityService) EnsureFreshToken(ctx context.Context, community *Comm } // 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 diff --git a/internal/db/postgres/community_repo.go b/internal/db/postgres/community_repo.go index 31f3685..1ae1048 100644 --- a/internal/db/postgres/community_repo.go +++ b/internal/db/postgres/community_repo.go @@ -344,7 +344,7 @@ func (r *postgresCommunityRepo) Delete(ctx context.Context, did string) error { } // 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{}{} @@ -356,37 +356,42 @@ func (r *postgresCommunityRepo) List(ctx context.Context, req communities.ListCo 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 - sortColumn := "created_at" - if req.SortBy != "" { - switch req.SortBy { - case "member_count", "subscriber_count", "post_count", "created_at": - sortColumn = req.SortBy - } - } - + // Build sort clause - map sort enum to DB columns + sortColumn := "subscriber_count" // default: popular 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 @@ -407,7 +412,7 @@ func (r *postgresCommunityRepo) List(ctx context.Context, req communities.ListCo 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 { @@ -436,7 +441,7 @@ func (r *postgresCommunityRepo) List(ctx context.Context, req communities.ListCo &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 @@ -458,10 +463,10 @@ func (r *postgresCommunityRepo) List(ctx context.Context, req communities.ListCo } 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