A community based topic aggregation platform built on atproto

feat(community-suggestions): add off-protocol suggestion and voting system

Implement a complete community suggestions feature allowing users to propose
ideas and vote on them. This is off-protocol (not stored on PDS/firehose)
and uses PostgreSQL directly for storage.

Changes:
- Add CRUD endpoints for community suggestions (create, get, list)
- Add voting with toggle semantics and atomic vote count updates
- Add admin-only status management (open/under_review/approved/declined)
- Add rate limiting: 3 suggestions/day per user, 10 req/min create, 30 req/min vote
- Add PostgreSQL migration (030) with community_suggestions and suggestion_votes tables
- Add repository with row-level locking for consistent vote counting
- Extract shared xrpc.WriteError() helper, refactor adminreport to use it
- Add Caddy proxy port (8080) to mobile port forwarding script
- Add comprehensive E2E integration tests

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

+3094 -22
+66
.claude/commands/fix-pr.md
··· 1 + # Fix PR Comments 2 + 3 + Analyze the current changed work and fix PR review comments using parallel subagents to avoid context rot. 4 + 5 + ## Input 6 + 7 + The user will paste PR review comments after the command. The comments are provided below: 8 + 9 + $ARGUMENTS 10 + 11 + ## Workflow 12 + 13 + ### Step 1: Analyze Current State 14 + 15 + Run these commands to understand the full scope of changes: 16 + 1. `git diff --stat` — overview of changed files 17 + 2. `git diff` — full diff of all current changes (staged + unstaged) 18 + 3. `git status` — current working tree state 19 + 20 + Read through the diff carefully. Build a mental model of what was changed, which files are involved, and the architecture of the changes. 21 + 22 + ### Step 2: Parse and Group the PR Comments 23 + 24 + From the pasted PR comments above, identify every distinct issue. Group them into **2-3 independent chunks** based on: 25 + - Which files they touch (keep file-adjacent issues together) 26 + - Logical coupling (issues that affect each other should be in the same chunk) 27 + - Roughly equal workload per chunk 28 + 29 + **Grouping heuristic:** 30 + - **≤3 issues total** → 2 chunks 31 + - **4+ issues total** → 3 chunks 32 + - Never put tightly coupled issues in different chunks (e.g., if fixing issue A changes code that issue B also references, they go together) 33 + 34 + ### Step 3: Launch Subagents in Parallel 35 + 36 + For each chunk, launch a `general-purpose` subagent via the Task tool. All subagents should be launched in a **single message** so they run concurrently (foreground, not background). 37 + 38 + Each subagent prompt MUST include: 39 + 1. **The full git diff** (or the relevant portions for their files) so they understand the current state 40 + 2. **The specific PR comments** they are responsible for fixing, quoted verbatim 41 + 3. **Clear instructions**: fix the issues described, follow CLAUDE.md guidelines, and run `go vet ./...` after making changes to verify correctness 42 + 4. **File scope**: explicitly list which files they should be reading/editing 43 + 44 + **Important subagent instructions to include:** 45 + - "You are fixing PR review comments on existing changed code. Read the relevant files first, then make targeted fixes." 46 + - "After making changes, run `go vet ./...` and `go build ./...` to verify no issues were introduced." 47 + - "Follow all CLAUDE.md guidelines — parameterized queries, proper error handling, context.Context everywhere, no stubs." 48 + - "Do NOT refactor beyond what the PR comment asks for. Make minimal, focused fixes." 49 + 50 + ### Step 3: Review Results 51 + 52 + After all subagents complete: 53 + 1. Run `go vet ./...` and `go build ./...` to verify the full project compiles cleanly 54 + 2. Run `git diff --stat` to summarize what was changed 55 + 3. Report to the user: 56 + - Which PR comments were addressed 57 + - What changes were made 58 + - Whether `go vet` and `go build` pass 59 + - Any comments that couldn't be fully addressed and why 60 + 61 + ## Notes 62 + 63 + - The goal is **isolated, focused fixes** — each subagent works on its own slice without polluting context for other fixes. 64 + - If two subagents need to edit the same file, group those issues together in one chunk to avoid conflicts. 65 + - Prefer fewer, larger chunks over many tiny ones — the overhead of each subagent matters. 66 + - If a PR comment is unclear or seems wrong, flag it in the results rather than guessing.
+5 -1
CLAUDE.md
··· 118 118 119 119 Remember: We're building a working product. Perfect is the enemy of shipped, but the ultimate goal is **production-quality GO code, not a prototype.** 120 120 121 - Every line of code should be something you'd be proud to ship in a production system. Quality over speed. Completeness over convenience. 121 + Every line of code should be something you'd be proud to ship in a production system. Quality over speed. Completeness over convenience. 122 + 123 + ## Subagent Execution 124 + 125 + When launching subagents via the Task tool, always run them in the **foreground** (`run_in_background: false`). Do not use background execution for subagents.
+16
cmd/server/main.go
··· 41 41 "Coves/internal/core/timeline" 42 42 "Coves/internal/core/unfurl" 43 43 "Coves/internal/core/adminreports" 44 + "Coves/internal/core/communitysuggestions" 44 45 "Coves/internal/core/userblocks" 45 46 "Coves/internal/core/users" 46 47 "Coves/internal/core/votes" ··· 630 631 adminReportService := adminreports.NewService(adminReportRepo) 631 632 log.Println("✅ Admin report service initialized (for flagging serious content)") 632 633 634 + // Initialize community suggestion service (off-protocol suggestion & voting) 635 + communitySuggestionRepo := postgresRepo.NewCommunitySuggestionRepository(db) 636 + communitySuggestionService := communitysuggestions.NewService(communitySuggestionRepo) 637 + log.Println("✅ Community suggestion service initialized") 638 + 633 639 // Initialize feed service 634 640 feedRepo := postgresRepo.NewCommunityFeedRepository(db, cursorSecret) 635 641 feedService := communityFeeds.NewCommunityFeedService(feedRepo, communityService) ··· 827 833 routes.RegisterAdminReportRoutes(r, adminReportService, authMiddleware) 828 834 log.Println("✅ Admin report endpoint registered (requires OAuth)") 829 835 log.Println(" - POST /xrpc/social.coves.admin.submitReport") 836 + 837 + // Register community suggestion routes (off-protocol suggestion & voting) 838 + routes.RegisterCommunitySuggestionRoutes(r, communitySuggestionService, authMiddleware, allowedCommunityCreators) 839 + log.Println("Community suggestion endpoints registered (off-protocol)") 840 + log.Println(" - POST /xrpc/social.coves.community.suggestion.create (requires OAuth, rate limited)") 841 + log.Println(" - GET /xrpc/social.coves.community.suggestion.list (optional auth)") 842 + log.Println(" - GET /xrpc/social.coves.community.suggestion.get (optional auth)") 843 + log.Println(" - POST /xrpc/social.coves.community.suggestion.vote (requires OAuth)") 844 + log.Println(" - POST /xrpc/social.coves.community.suggestion.removeVote (requires OAuth)") 845 + log.Println(" - POST /xrpc/social.coves.community.suggestion.updateStatus (admin only)") 830 846 831 847 routes.RegisterCommunityFeedRoutes(r, feedService, voteService, blueskyService, authMiddleware) 832 848 log.Println("Feed XRPC endpoints registered (public with optional auth for viewer vote state)")
+2 -19
internal/api/handlers/adminreport/errors.go
··· 1 1 package adminreport 2 2 3 3 import ( 4 + "Coves/internal/api/xrpc" 4 5 "Coves/internal/core/adminreports" 5 - "encoding/json" 6 6 "errors" 7 7 "log" 8 8 "net/http" 9 9 ) 10 10 11 - // errorResponse represents a standardized JSON error response 12 - type errorResponse struct { 13 - Error string `json:"error"` 14 - Message string `json:"message"` 15 - } 16 - 17 11 // writeError writes a JSON error response with the given status code 18 12 func writeError(w http.ResponseWriter, statusCode int, errorType, message string) { 19 - w.Header().Set("Content-Type", "application/json") 20 - w.WriteHeader(statusCode) 21 - if err := json.NewEncoder(w).Encode(errorResponse{ 22 - Error: errorType, 23 - Message: message, 24 - }); err != nil { 25 - log.Printf("Failed to encode error response: %v", err) 26 - } 13 + xrpc.WriteError(w, statusCode, errorType, message) 27 14 } 28 15 29 16 // handleServiceError maps service-layer errors to HTTP responses 30 17 func handleServiceError(w http.ResponseWriter, err error) { 31 18 switch { 32 19 case adminreports.IsValidationError(err): 33 - // Map specific validation errors to appropriate messages 34 20 switch { 35 21 case errors.Is(err, adminreports.ErrInvalidReason): 36 22 writeError(w, http.StatusBadRequest, "InvalidReason", ··· 51 37 writeError(w, http.StatusBadRequest, "InvalidTargetType", 52 38 "Invalid target type. Must be one of: post, comment") 53 39 default: 54 - // SECURITY: Don't expose internal error messages to clients 55 - // Log the actual error for debugging, but return a generic message 56 40 log.Printf("Unhandled validation error in admin report handler: %v", err) 57 41 writeError(w, http.StatusBadRequest, "InvalidRequest", 58 42 "The request contains invalid data") ··· 62 46 writeError(w, http.StatusNotFound, "NotFound", "Report not found") 63 47 64 48 default: 65 - // SECURITY: Don't leak internal error details to clients 66 49 log.Printf("Unexpected error in admin report handler: %v", err) 67 50 writeError(w, http.StatusInternalServerError, "InternalServerError", 68 51 "An internal error occurred")
+3 -2
internal/api/handlers/adminreport/submit_test.go
··· 2 2 3 3 import ( 4 4 "Coves/internal/api/middleware" 5 + "Coves/internal/api/xrpc" 5 6 "Coves/internal/core/adminreports" 6 7 "bytes" 7 8 "context" ··· 416 417 t.Errorf("expected status %d, got %d", http.StatusBadRequest, w.Code) 417 418 } 418 419 419 - var resp errorResponse 420 + var resp xrpc.Error 420 421 if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { 421 422 t.Fatalf("failed to unmarshal error response: %v", err) 422 423 } ··· 495 496 t.Errorf("expected status %d, got %d", tt.expectedStatus, w.Code) 496 497 } 497 498 498 - var resp errorResponse 499 + var resp xrpc.Error 499 500 if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { 500 501 t.Fatalf("failed to unmarshal error response: %v", err) 501 502 }
+81
internal/api/handlers/communitysuggestion/create.go
··· 1 + package communitysuggestion 2 + 3 + import ( 4 + "Coves/internal/api/middleware" 5 + "Coves/internal/core/communitysuggestions" 6 + "encoding/json" 7 + "log" 8 + "net/http" 9 + ) 10 + 11 + // CreateHandler handles community suggestion creation 12 + type CreateHandler struct { 13 + service communitysuggestions.Service 14 + } 15 + 16 + // NewCreateHandler creates a new create handler 17 + func NewCreateHandler(service communitysuggestions.Service) *CreateHandler { 18 + return &CreateHandler{ 19 + service: service, 20 + } 21 + } 22 + 23 + // createSuggestionInput represents the JSON request body for creating a suggestion 24 + type createSuggestionInput struct { 25 + Title string `json:"title"` 26 + Description string `json:"description"` 27 + } 28 + 29 + // HandleCreate creates a new community suggestion 30 + // POST /xrpc/social.coves.community.suggestion.create 31 + func (h *CreateHandler) HandleCreate(w http.ResponseWriter, r *http.Request) { 32 + if r.Method != http.MethodPost { 33 + writeError(w, http.StatusMethodNotAllowed, "MethodNotAllowed", "Method not allowed") 34 + return 35 + } 36 + 37 + // Limit request body size to 10KB to prevent DoS attacks 38 + r.Body = http.MaxBytesReader(w, r.Body, 10*1024) 39 + 40 + // Parse JSON body 41 + var input createSuggestionInput 42 + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { 43 + log.Printf("[COMMUNITY_SUGGESTION] Failed to decode JSON request: %v", err) 44 + writeError(w, http.StatusBadRequest, "InvalidRequest", "Invalid request body") 45 + return 46 + } 47 + 48 + // Extract authenticated user DID from request context (injected by auth middleware) 49 + userDID := middleware.GetUserDID(r) 50 + if userDID == "" { 51 + writeError(w, http.StatusUnauthorized, "AuthRequired", "Authentication required") 52 + return 53 + } 54 + 55 + // Build the create request 56 + req := communitysuggestions.CreateSuggestionRequest{ 57 + Title: input.Title, 58 + Description: input.Description, 59 + SubmitterDID: userDID, 60 + } 61 + 62 + // Create suggestion via service 63 + suggestion, err := h.service.CreateSuggestion(r.Context(), req) 64 + if err != nil { 65 + handleServiceError(w, err) 66 + return 67 + } 68 + 69 + // Return full suggestion JSON on success 70 + data, err := json.Marshal(suggestion) 71 + if err != nil { 72 + log.Printf("Failed to marshal create suggestion response: %v", err) 73 + writeError(w, http.StatusInternalServerError, "InternalServerError", "An internal error occurred") 74 + return 75 + } 76 + w.Header().Set("Content-Type", "application/json") 77 + w.WriteHeader(http.StatusOK) 78 + if _, err := w.Write(data); err != nil { 79 + log.Printf("Failed to write response: %v", err) 80 + } 81 + }
+55
internal/api/handlers/communitysuggestion/errors.go
··· 1 + package communitysuggestion 2 + 3 + import ( 4 + "Coves/internal/api/xrpc" 5 + "Coves/internal/core/communitysuggestions" 6 + "errors" 7 + "log" 8 + "net/http" 9 + ) 10 + 11 + // writeError writes an XRPC error response 12 + func writeError(w http.ResponseWriter, status int, error, message string) { 13 + xrpc.WriteError(w, status, error, message) 14 + } 15 + 16 + // handleServiceError converts service errors to appropriate HTTP responses 17 + // Each sentinel error is mapped to a static, user-facing message to prevent 18 + // leaking internal error details to clients. 19 + func handleServiceError(w http.ResponseWriter, err error) { 20 + switch { 21 + case communitysuggestions.IsValidationError(err): 22 + switch { 23 + case errors.Is(err, communitysuggestions.ErrTitleRequired): 24 + writeError(w, http.StatusBadRequest, "InvalidRequest", "Suggestion title is required") 25 + case errors.Is(err, communitysuggestions.ErrTitleTooLong): 26 + writeError(w, http.StatusBadRequest, "InvalidRequest", "Suggestion title exceeds maximum length") 27 + case errors.Is(err, communitysuggestions.ErrDescriptionRequired): 28 + writeError(w, http.StatusBadRequest, "InvalidRequest", "Suggestion description is required") 29 + case errors.Is(err, communitysuggestions.ErrDescriptionTooLong): 30 + writeError(w, http.StatusBadRequest, "InvalidRequest", "Suggestion description exceeds maximum length") 31 + case errors.Is(err, communitysuggestions.ErrInvalidStatus): 32 + writeError(w, http.StatusBadRequest, "InvalidStatus", "Invalid status value. Must be one of: open, under_review, approved, declined") 33 + case errors.Is(err, communitysuggestions.ErrInvalidVoteValue): 34 + writeError(w, http.StatusBadRequest, "InvalidVoteValue", "Invalid vote value. Must be 1 or -1") 35 + case errors.Is(err, communitysuggestions.ErrInvalidSuggestionID): 36 + writeError(w, http.StatusBadRequest, "InvalidRequest", "Invalid suggestion ID") 37 + case errors.Is(err, communitysuggestions.ErrVoterRequired): 38 + writeError(w, http.StatusBadRequest, "InvalidRequest", "Voter identification is required") 39 + case errors.Is(err, communitysuggestions.ErrSubmitterRequired): 40 + writeError(w, http.StatusBadRequest, "InvalidRequest", "Submitter identification is required") 41 + default: 42 + log.Printf("Unhandled validation error in community suggestion handler: %v", err) 43 + writeError(w, http.StatusBadRequest, "InvalidRequest", "The request contains invalid data") 44 + } 45 + case communitysuggestions.IsNotFound(err): 46 + writeError(w, http.StatusNotFound, "NotFound", "The requested resource was not found") 47 + case communitysuggestions.IsRateLimitError(err): 48 + writeError(w, http.StatusTooManyRequests, "RateLimitExceeded", "Too many suggestions. Please try again later") 49 + case communitysuggestions.IsAuthorizationError(err): 50 + writeError(w, http.StatusForbidden, "Forbidden", "You are not authorized to perform this action") 51 + default: 52 + log.Printf("XRPC community suggestion handler error: %v", err) 53 + writeError(w, http.StatusInternalServerError, "InternalServerError", "An internal error occurred") 54 + } 55 + }
+67
internal/api/handlers/communitysuggestion/get.go
··· 1 + package communitysuggestion 2 + 3 + import ( 4 + "Coves/internal/api/middleware" 5 + "Coves/internal/core/communitysuggestions" 6 + "encoding/json" 7 + "log" 8 + "net/http" 9 + "strconv" 10 + ) 11 + 12 + // GetHandler handles retrieving a single community suggestion 13 + type GetHandler struct { 14 + service communitysuggestions.Service 15 + } 16 + 17 + // NewGetHandler creates a new get handler 18 + func NewGetHandler(service communitysuggestions.Service) *GetHandler { 19 + return &GetHandler{ 20 + service: service, 21 + } 22 + } 23 + 24 + // HandleGet retrieves a single community suggestion by ID 25 + // GET /xrpc/social.coves.community.suggestion.get?id=123 26 + func (h *GetHandler) HandleGet(w http.ResponseWriter, r *http.Request) { 27 + if r.Method != http.MethodGet { 28 + writeError(w, http.StatusMethodNotAllowed, "MethodNotAllowed", "Method not allowed") 29 + return 30 + } 31 + 32 + // Parse id query param 33 + idStr := r.URL.Query().Get("id") 34 + if idStr == "" { 35 + writeError(w, http.StatusBadRequest, "InvalidRequest", "Missing required parameter: id") 36 + return 37 + } 38 + 39 + id, err := strconv.ParseInt(idStr, 10, 64) 40 + if err != nil || id <= 0 { 41 + writeError(w, http.StatusBadRequest, "InvalidRequest", "Invalid id parameter: must be a positive integer") 42 + return 43 + } 44 + 45 + // Extract optional DID for viewer state 46 + viewerDID := middleware.GetUserDID(r) 47 + 48 + // Get suggestion via service 49 + suggestion, err := h.service.GetSuggestion(r.Context(), id, viewerDID) 50 + if err != nil { 51 + handleServiceError(w, err) 52 + return 53 + } 54 + 55 + // Return full suggestion JSON 56 + data, err := json.Marshal(suggestion) 57 + if err != nil { 58 + log.Printf("Failed to marshal get suggestion response: %v", err) 59 + writeError(w, http.StatusInternalServerError, "InternalServerError", "An internal error occurred") 60 + return 61 + } 62 + w.Header().Set("Content-Type", "application/json") 63 + w.WriteHeader(http.StatusOK) 64 + if _, err := w.Write(data); err != nil { 65 + log.Printf("Failed to write response: %v", err) 66 + } 67 + }
+128
internal/api/handlers/communitysuggestion/list.go
··· 1 + package communitysuggestion 2 + 3 + import ( 4 + "Coves/internal/api/middleware" 5 + "Coves/internal/core/communitysuggestions" 6 + "encoding/json" 7 + "log" 8 + "net/http" 9 + "strconv" 10 + ) 11 + 12 + // ListHandler handles listing community suggestions 13 + type ListHandler struct { 14 + service communitysuggestions.Service 15 + } 16 + 17 + // NewListHandler creates a new list handler 18 + func NewListHandler(service communitysuggestions.Service) *ListHandler { 19 + return &ListHandler{ 20 + service: service, 21 + } 22 + } 23 + 24 + // HandleList lists community suggestions with filters 25 + // GET /xrpc/social.coves.community.suggestion.list?sort=popular&status=open&limit=50&cursor=0 26 + func (h *ListHandler) HandleList(w http.ResponseWriter, r *http.Request) { 27 + if r.Method != http.MethodGet { 28 + writeError(w, http.StatusMethodNotAllowed, "MethodNotAllowed", "Method not allowed") 29 + return 30 + } 31 + 32 + // Parse query parameters 33 + query := r.URL.Query() 34 + 35 + // Parse sort (default "popular", valid: "popular"|"new") 36 + sort := query.Get("sort") 37 + if sort == "" { 38 + sort = "popular" 39 + } 40 + validSorts := map[string]bool{ 41 + "popular": true, 42 + "new": true, 43 + } 44 + if !validSorts[sort] { 45 + writeError(w, http.StatusBadRequest, "InvalidRequest", "Invalid sort value. Must be: popular or new") 46 + return 47 + } 48 + 49 + // Parse status (optional, validate if present) 50 + status := query.Get("status") 51 + if status != "" && !communitysuggestions.IsValidStatus(status) { 52 + writeError(w, http.StatusBadRequest, "InvalidRequest", "Invalid status value. Must be one of: open, under_review, approved, declined") 53 + return 54 + } 55 + 56 + // Parse limit (1-100, default 50) 57 + limit := 50 58 + if limitStr := query.Get("limit"); limitStr != "" { 59 + l, err := strconv.Atoi(limitStr) 60 + if err != nil { 61 + writeError(w, http.StatusBadRequest, "InvalidRequest", "Invalid limit parameter: must be an integer") 62 + return 63 + } 64 + if l < 1 || l > 100 { 65 + writeError(w, http.StatusBadRequest, "InvalidRequest", "Invalid limit parameter: must be between 1 and 100") 66 + return 67 + } 68 + limit = l 69 + } 70 + 71 + // Parse cursor (offset-based) 72 + offset := 0 73 + if cursorStr := query.Get("cursor"); cursorStr != "" { 74 + o, err := strconv.Atoi(cursorStr) 75 + if err != nil { 76 + writeError(w, http.StatusBadRequest, "InvalidRequest", "Invalid cursor parameter: must be an integer") 77 + return 78 + } 79 + if o < 0 { 80 + writeError(w, http.StatusBadRequest, "InvalidRequest", "Invalid cursor parameter: must be non-negative") 81 + return 82 + } 83 + offset = o 84 + } 85 + 86 + // Extract optional DID for viewer state 87 + viewerDID := middleware.GetUserDID(r) 88 + 89 + // Build the list request 90 + req := communitysuggestions.ListSuggestionsRequest{ 91 + Sort: sort, 92 + Status: status, 93 + Limit: limit, 94 + Offset: offset, 95 + ViewerDID: viewerDID, 96 + } 97 + 98 + // List suggestions via service 99 + suggestions, err := h.service.ListSuggestions(r.Context(), req) 100 + if err != nil { 101 + handleServiceError(w, err) 102 + return 103 + } 104 + 105 + // Build cursor: next offset when there are more results 106 + var cursor string 107 + if len(suggestions) == limit { 108 + cursor = strconv.Itoa(offset + len(suggestions)) 109 + } 110 + 111 + // Build response 112 + response := map[string]interface{}{ 113 + "suggestions": suggestions, 114 + "cursor": cursor, 115 + } 116 + 117 + data, err := json.Marshal(response) 118 + if err != nil { 119 + log.Printf("Failed to marshal suggestion list response: %v", err) 120 + writeError(w, http.StatusInternalServerError, "InternalServerError", "An internal error occurred") 121 + return 122 + } 123 + w.Header().Set("Content-Type", "application/json") 124 + w.WriteHeader(http.StatusOK) 125 + if _, err := w.Write(data); err != nil { 126 + log.Printf("Failed to write response: %v", err) 127 + } 128 + }
+97
internal/api/handlers/communitysuggestion/update_status.go
··· 1 + package communitysuggestion 2 + 3 + import ( 4 + "Coves/internal/api/middleware" 5 + "Coves/internal/core/communitysuggestions" 6 + "encoding/json" 7 + "log" 8 + "net/http" 9 + ) 10 + 11 + // UpdateStatusHandler handles updating a community suggestion's status (admin only) 12 + type UpdateStatusHandler struct { 13 + service communitysuggestions.Service 14 + adminDIDs map[string]bool 15 + } 16 + 17 + // NewUpdateStatusHandler creates a new update status handler 18 + // adminDIDs is a list of DIDs that can update suggestion status 19 + func NewUpdateStatusHandler(service communitysuggestions.Service, adminDIDs []string) *UpdateStatusHandler { 20 + var adminMap map[string]bool 21 + if len(adminDIDs) > 0 { 22 + adminMap = make(map[string]bool) 23 + for _, did := range adminDIDs { 24 + if did != "" { // Skip empty strings 25 + adminMap[did] = true 26 + } 27 + } 28 + // If all entries were empty, no admins are configured — block all access 29 + if len(adminMap) == 0 { 30 + adminMap = nil 31 + log.Printf("[WARN] All admin DID entries were empty — suggestion status updates will be blocked for all users") 32 + } 33 + } 34 + return &UpdateStatusHandler{ 35 + service: service, 36 + adminDIDs: adminMap, 37 + } 38 + } 39 + 40 + // updateStatusInput represents the JSON request body for updating a suggestion's status 41 + type updateStatusInput struct { 42 + SuggestionID int64 `json:"suggestionId"` 43 + Status string `json:"status"` 44 + } 45 + 46 + // HandleUpdateStatus updates a community suggestion's status 47 + // POST /xrpc/social.coves.community.suggestion.updateStatus 48 + func (h *UpdateStatusHandler) HandleUpdateStatus(w http.ResponseWriter, r *http.Request) { 49 + if r.Method != http.MethodPost { 50 + writeError(w, http.StatusMethodNotAllowed, "MethodNotAllowed", "Method not allowed") 51 + return 52 + } 53 + 54 + // Extract authenticated user DID 55 + userDID := middleware.GetUserDID(r) 56 + if userDID == "" { 57 + writeError(w, http.StatusUnauthorized, "AuthRequired", "Authentication required") 58 + return 59 + } 60 + 61 + // Check if user is an admin 62 + if h.adminDIDs == nil || !h.adminDIDs[userDID] { 63 + writeError(w, http.StatusForbidden, "Forbidden", "Admin access required") 64 + return 65 + } 66 + 67 + // Limit request body size to 10KB 68 + r.Body = http.MaxBytesReader(w, r.Body, 10*1024) 69 + 70 + // Parse JSON body 71 + var input updateStatusInput 72 + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { 73 + log.Printf("[COMMUNITY_SUGGESTION] Failed to decode update status JSON request: %v", err) 74 + writeError(w, http.StatusBadRequest, "InvalidRequest", "Invalid request body") 75 + return 76 + } 77 + 78 + // Build the update status request 79 + req := communitysuggestions.UpdateStatusRequest{ 80 + SuggestionID: input.SuggestionID, 81 + Status: communitysuggestions.Status(input.Status), 82 + AdminDID: userDID, 83 + } 84 + 85 + // Update status via service 86 + if err := h.service.UpdateStatus(r.Context(), req); err != nil { 87 + handleServiceError(w, err) 88 + return 89 + } 90 + 91 + // Return success 92 + w.Header().Set("Content-Type", "application/json") 93 + w.WriteHeader(http.StatusOK) 94 + if _, err := w.Write([]byte(`{"success":true}`)); err != nil { 95 + log.Printf("Failed to write response: %v", err) 96 + } 97 + }
+119
internal/api/handlers/communitysuggestion/vote.go
··· 1 + package communitysuggestion 2 + 3 + import ( 4 + "Coves/internal/api/middleware" 5 + "Coves/internal/core/communitysuggestions" 6 + "encoding/json" 7 + "log" 8 + "net/http" 9 + ) 10 + 11 + // VoteHandler handles voting on community suggestions 12 + type VoteHandler struct { 13 + service communitysuggestions.Service 14 + } 15 + 16 + // NewVoteHandler creates a new vote handler 17 + func NewVoteHandler(service communitysuggestions.Service) *VoteHandler { 18 + return &VoteHandler{ 19 + service: service, 20 + } 21 + } 22 + 23 + // voteInput represents the JSON request body for casting a vote 24 + type voteInput struct { 25 + SuggestionID int64 `json:"suggestionId"` 26 + Value int `json:"value"` 27 + } 28 + 29 + // removeVoteInput represents the JSON request body for removing a vote 30 + type removeVoteInput struct { 31 + SuggestionID int64 `json:"suggestionId"` 32 + } 33 + 34 + // HandleVote casts or toggles a vote on a community suggestion 35 + // POST /xrpc/social.coves.community.suggestion.vote 36 + func (h *VoteHandler) HandleVote(w http.ResponseWriter, r *http.Request) { 37 + if r.Method != http.MethodPost { 38 + writeError(w, http.StatusMethodNotAllowed, "MethodNotAllowed", "Method not allowed") 39 + return 40 + } 41 + 42 + // Limit request body size to 10KB 43 + r.Body = http.MaxBytesReader(w, r.Body, 10*1024) 44 + 45 + // Parse JSON body 46 + var input voteInput 47 + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { 48 + log.Printf("[COMMUNITY_SUGGESTION] Failed to decode vote JSON request: %v", err) 49 + writeError(w, http.StatusBadRequest, "InvalidRequest", "Invalid request body") 50 + return 51 + } 52 + 53 + // Extract authenticated user DID 54 + userDID := middleware.GetUserDID(r) 55 + if userDID == "" { 56 + writeError(w, http.StatusUnauthorized, "AuthRequired", "Authentication required") 57 + return 58 + } 59 + 60 + // Build the vote request 61 + req := communitysuggestions.VoteRequest{ 62 + SuggestionID: input.SuggestionID, 63 + VoterDID: userDID, 64 + Value: input.Value, 65 + } 66 + 67 + // Cast vote via service 68 + if err := h.service.Vote(r.Context(), req); err != nil { 69 + handleServiceError(w, err) 70 + return 71 + } 72 + 73 + // Return success 74 + w.Header().Set("Content-Type", "application/json") 75 + w.WriteHeader(http.StatusOK) 76 + if _, err := w.Write([]byte(`{"success":true}`)); err != nil { 77 + log.Printf("Failed to write response: %v", err) 78 + } 79 + } 80 + 81 + // HandleRemoveVote removes a vote from a community suggestion 82 + // POST /xrpc/social.coves.community.suggestion.removeVote 83 + func (h *VoteHandler) HandleRemoveVote(w http.ResponseWriter, r *http.Request) { 84 + if r.Method != http.MethodPost { 85 + writeError(w, http.StatusMethodNotAllowed, "MethodNotAllowed", "Method not allowed") 86 + return 87 + } 88 + 89 + // Limit request body size to 10KB 90 + r.Body = http.MaxBytesReader(w, r.Body, 10*1024) 91 + 92 + // Parse JSON body 93 + var input removeVoteInput 94 + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { 95 + log.Printf("[COMMUNITY_SUGGESTION] Failed to decode remove vote JSON request: %v", err) 96 + writeError(w, http.StatusBadRequest, "InvalidRequest", "Invalid request body") 97 + return 98 + } 99 + 100 + // Extract authenticated user DID 101 + userDID := middleware.GetUserDID(r) 102 + if userDID == "" { 103 + writeError(w, http.StatusUnauthorized, "AuthRequired", "Authentication required") 104 + return 105 + } 106 + 107 + // Remove vote via service 108 + if err := h.service.RemoveVote(r.Context(), input.SuggestionID, userDID); err != nil { 109 + handleServiceError(w, err) 110 + return 111 + } 112 + 113 + // Return success 114 + w.Header().Set("Content-Type", "application/json") 115 + w.WriteHeader(http.StatusOK) 116 + if _, err := w.Write([]byte(`{"success":true}`)); err != nil { 117 + log.Printf("Failed to write response: %v", err) 118 + } 119 + }
+76
internal/api/routes/communitysuggestion.go
··· 1 + package routes 2 + 3 + import ( 4 + "Coves/internal/api/handlers/communitysuggestion" 5 + "Coves/internal/api/middleware" 6 + "Coves/internal/core/communitysuggestions" 7 + "time" 8 + 9 + "github.com/go-chi/chi/v5" 10 + ) 11 + 12 + // RegisterCommunitySuggestionRoutes registers community suggestion XRPC endpoints on the router 13 + // Implements social.coves.community.suggestion.* endpoints for community suggestions and voting 14 + // adminDIDs restricts who can update suggestion status (reuses COMMUNITY_CREATORS env var) 15 + func RegisterCommunitySuggestionRoutes( 16 + r chi.Router, 17 + service communitysuggestions.Service, 18 + authMiddleware *middleware.OAuthAuthMiddleware, 19 + adminDIDs []string, 20 + ) { 21 + // Initialize handlers 22 + createHandler := communitysuggestion.NewCreateHandler(service) 23 + listHandler := communitysuggestion.NewListHandler(service) 24 + getHandler := communitysuggestion.NewGetHandler(service) 25 + voteHandler := communitysuggestion.NewVoteHandler(service) 26 + updateStatusHandler := communitysuggestion.NewUpdateStatusHandler(service, adminDIDs) 27 + 28 + // Create IP rate limiter for suggestion creation 29 + // Allow 10 requests per minute per IP to prevent abuse 30 + createRateLimiter := middleware.NewRateLimiter(10, time.Minute) 31 + 32 + // Create IP rate limiter for voting 33 + // Allow 30 requests per minute per IP to prevent vote-toggle abuse 34 + voteRateLimiter := middleware.NewRateLimiter(30, time.Minute) 35 + 36 + // Query endpoints (GET) - public access, optional auth for viewer state 37 + // social.coves.community.suggestion.list - list suggestions with filters 38 + r.With(authMiddleware.OptionalAuth).Get( 39 + "/xrpc/social.coves.community.suggestion.list", 40 + listHandler.HandleList) 41 + 42 + // social.coves.community.suggestion.get - get a single suggestion by ID 43 + r.With(authMiddleware.OptionalAuth).Get( 44 + "/xrpc/social.coves.community.suggestion.get", 45 + getHandler.HandleGet) 46 + 47 + // Procedure endpoints (POST) - require authentication 48 + // social.coves.community.suggestion.create - create a new suggestion 49 + r.With( 50 + createRateLimiter.Middleware, 51 + authMiddleware.RequireAuth, 52 + ).Post( 53 + "/xrpc/social.coves.community.suggestion.create", 54 + createHandler.HandleCreate) 55 + 56 + // social.coves.community.suggestion.vote - cast or toggle a vote 57 + r.With( 58 + voteRateLimiter.Middleware, 59 + authMiddleware.RequireAuth, 60 + ).Post( 61 + "/xrpc/social.coves.community.suggestion.vote", 62 + voteHandler.HandleVote) 63 + 64 + // social.coves.community.suggestion.removeVote - remove a vote 65 + r.With( 66 + voteRateLimiter.Middleware, 67 + authMiddleware.RequireAuth, 68 + ).Post( 69 + "/xrpc/social.coves.community.suggestion.removeVote", 70 + voteHandler.HandleRemoveVote) 71 + 72 + // social.coves.community.suggestion.updateStatus - update suggestion status (admin only) 73 + r.With(authMiddleware.RequireAuth).Post( 74 + "/xrpc/social.coves.community.suggestion.updateStatus", 75 + updateStatusHandler.HandleUpdateStatus) 76 + }
+25
internal/api/xrpc/errors.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "log" 6 + "net/http" 7 + ) 8 + 9 + // Error represents an XRPC error response 10 + type Error struct { 11 + Error string `json:"error"` 12 + Message string `json:"message"` 13 + } 14 + 15 + // WriteError writes an XRPC error response with the given status code 16 + func WriteError(w http.ResponseWriter, statusCode int, errorType, message string) { 17 + w.Header().Set("Content-Type", "application/json") 18 + w.WriteHeader(statusCode) 19 + if err := json.NewEncoder(w).Encode(Error{ 20 + Error: errorType, 21 + Message: message, 22 + }); err != nil { 23 + log.Printf("Failed to encode XRPC error response: %v", err) 24 + } 25 + }
+73
internal/core/communitysuggestions/errors.go
··· 1 + package communitysuggestions 2 + 3 + import "errors" 4 + 5 + var ( 6 + // ErrSuggestionNotFound indicates the requested suggestion does not exist 7 + ErrSuggestionNotFound = errors.New("suggestion not found") 8 + 9 + // ErrTitleRequired indicates the suggestion title was not provided 10 + ErrTitleRequired = errors.New("suggestion title is required") 11 + 12 + // ErrTitleTooLong indicates the suggestion title exceeds the maximum length 13 + ErrTitleTooLong = errors.New("suggestion title exceeds maximum length of 200 characters") 14 + 15 + // ErrDescriptionRequired indicates the suggestion description was not provided 16 + ErrDescriptionRequired = errors.New("suggestion description is required") 17 + 18 + // ErrDescriptionTooLong indicates the suggestion description exceeds the maximum length 19 + ErrDescriptionTooLong = errors.New("suggestion description exceeds maximum length of 5000 characters") 20 + 21 + // ErrInvalidStatus indicates the suggestion status is not a valid value 22 + ErrInvalidStatus = errors.New("invalid suggestion status: must be one of open, under_review, approved, declined") 23 + 24 + // ErrInvalidVoteValue indicates the vote value is not valid (must be 1 or -1) 25 + ErrInvalidVoteValue = errors.New("invalid vote value: must be 1 or -1") 26 + 27 + // ErrInvalidSuggestionID indicates the suggestion ID is invalid 28 + ErrInvalidSuggestionID = errors.New("invalid suggestion ID: must be a positive integer") 29 + 30 + // ErrVoterRequired indicates the voter DID was not provided 31 + ErrVoterRequired = errors.New("voter DID is required") 32 + 33 + // ErrSubmitterRequired indicates the submitter DID was not provided 34 + ErrSubmitterRequired = errors.New("submitter DID is required") 35 + 36 + // ErrNotAuthorized indicates the user is not authorized to perform the action 37 + ErrNotAuthorized = errors.New("not authorized to perform this action") 38 + 39 + // ErrRateLimitExceeded indicates the user has exceeded the suggestion creation rate limit 40 + ErrRateLimitExceeded = errors.New("rate limit exceeded: maximum 3 suggestions per day") 41 + 42 + // ErrVoteNotFound indicates the requested vote does not exist 43 + ErrVoteNotFound = errors.New("vote not found") 44 + ) 45 + 46 + // IsValidationError checks if an error is a validation error 47 + func IsValidationError(err error) bool { 48 + return errors.Is(err, ErrTitleRequired) || 49 + errors.Is(err, ErrTitleTooLong) || 50 + errors.Is(err, ErrDescriptionRequired) || 51 + errors.Is(err, ErrDescriptionTooLong) || 52 + errors.Is(err, ErrInvalidStatus) || 53 + errors.Is(err, ErrInvalidVoteValue) || 54 + errors.Is(err, ErrInvalidSuggestionID) || 55 + errors.Is(err, ErrVoterRequired) || 56 + errors.Is(err, ErrSubmitterRequired) 57 + } 58 + 59 + // IsNotFound checks if an error is a "not found" error 60 + func IsNotFound(err error) bool { 61 + return errors.Is(err, ErrSuggestionNotFound) || 62 + errors.Is(err, ErrVoteNotFound) 63 + } 64 + 65 + // IsRateLimitError checks if an error is a rate limit error 66 + func IsRateLimitError(err error) bool { 67 + return errors.Is(err, ErrRateLimitExceeded) 68 + } 69 + 70 + // IsAuthorizationError checks if an error is an authorization error 71 + func IsAuthorizationError(err error) bool { 72 + return errors.Is(err, ErrNotAuthorized) 73 + }
+76
internal/core/communitysuggestions/interfaces.go
··· 1 + package communitysuggestions 2 + 3 + import ( 4 + "context" 5 + "time" 6 + ) 7 + 8 + // Repository defines the data access layer for community suggestions 9 + type Repository interface { 10 + // Create stores a new suggestion in the database 11 + // Returns the suggestion with ID, CreatedAt, and UpdatedAt populated 12 + Create(ctx context.Context, suggestion *CommunitySuggestion) error 13 + 14 + // GetByID retrieves a single suggestion by its ID 15 + // Returns ErrSuggestionNotFound if the suggestion does not exist 16 + GetByID(ctx context.Context, id int64) (*CommunitySuggestion, error) 17 + 18 + // List retrieves suggestions with optional filtering and sorting 19 + List(ctx context.Context, req ListSuggestionsRequest) ([]*CommunitySuggestion, error) 20 + 21 + // CountBySubmitterSince counts the number of suggestions created by a submitter since a given time 22 + // Used for rate limiting suggestion creation 23 + CountBySubmitterSince(ctx context.Context, submitterDID string, since time.Time) (int, error) 24 + 25 + // UpdateStatus updates a suggestion's status 26 + // Returns ErrSuggestionNotFound if the suggestion does not exist 27 + UpdateStatus(ctx context.Context, id int64, status Status) error 28 + 29 + // UpsertVote inserts or updates a vote for a suggestion 30 + // Returns the delta to apply to the suggestion's vote count 31 + UpsertVote(ctx context.Context, suggestionID int64, voterDID string, value int) (int, error) 32 + 33 + // DeleteVote removes a vote from a suggestion 34 + // Returns the delta to apply to the suggestion's vote count 35 + DeleteVote(ctx context.Context, suggestionID int64, voterDID string) (int, error) 36 + 37 + // GetVote retrieves a single vote by suggestion ID and voter DID 38 + // Returns ErrVoteNotFound if the vote does not exist 39 + GetVote(ctx context.Context, suggestionID int64, voterDID string) (*SuggestionVote, error) 40 + 41 + // GetVotesForViewer retrieves the votes cast by a viewer on a set of suggestions 42 + // Returns a map of suggestion ID to vote value 43 + GetVotesForViewer(ctx context.Context, voterDID string, suggestionIDs []int64) (map[int64]int, error) 44 + 45 + // AtomicVote atomically handles voting with toggle semantics in a single transaction 46 + // If no existing vote: creates the vote 47 + // If existing vote in same direction: removes the vote (toggle off) 48 + // If existing vote in opposite direction: flips the vote 49 + AtomicVote(ctx context.Context, suggestionID int64, voterDID string, value int) error 50 + } 51 + 52 + // Service defines the business logic layer for community suggestions 53 + type Service interface { 54 + // CreateSuggestion validates and creates a new community suggestion 55 + // Enforces rate limiting (max 3 suggestions per day per user) 56 + CreateSuggestion(ctx context.Context, req CreateSuggestionRequest) (*CommunitySuggestion, error) 57 + 58 + // GetSuggestion retrieves a single suggestion by ID 59 + // If viewerDID is non-empty, populates the viewer state with the viewer's vote 60 + GetSuggestion(ctx context.Context, id int64, viewerDID string) (*CommunitySuggestion, error) 61 + 62 + // ListSuggestions retrieves suggestions with filtering, sorting, and pagination 63 + // Populates viewer state for authenticated viewers 64 + ListSuggestions(ctx context.Context, req ListSuggestionsRequest) ([]*CommunitySuggestion, error) 65 + 66 + // Vote casts or toggles a vote on a suggestion 67 + // If the user already voted in the same direction, the vote is removed (toggle off) 68 + // If the user already voted in the opposite direction, the vote is flipped 69 + Vote(ctx context.Context, req VoteRequest) error 70 + 71 + // RemoveVote removes a user's vote from a suggestion 72 + RemoveVote(ctx context.Context, suggestionID int64, voterDID string) error 73 + 74 + // UpdateStatus updates a suggestion's status (admin only) 75 + UpdateStatus(ctx context.Context, req UpdateStatusRequest) error 76 + }
+133
internal/core/communitysuggestions/service.go
··· 1 + package communitysuggestions 2 + 3 + import ( 4 + "context" 5 + "time" 6 + ) 7 + 8 + // service implements the Service interface for community suggestions 9 + type service struct { 10 + repo Repository 11 + } 12 + 13 + // NewService creates a new community suggestions service 14 + func NewService(repo Repository) Service { 15 + return &service{ 16 + repo: repo, 17 + } 18 + } 19 + 20 + // CreateSuggestion validates the request, checks the rate limit, and creates a new suggestion 21 + func (s *service) CreateSuggestion(ctx context.Context, req CreateSuggestionRequest) (*CommunitySuggestion, error) { 22 + // Validate the request 23 + if err := req.Validate(); err != nil { 24 + return nil, err 25 + } 26 + 27 + // Check rate limit: max 3 suggestions per day per user 28 + since := time.Now().UTC().Add(-24 * time.Hour) 29 + count, err := s.repo.CountBySubmitterSince(ctx, req.SubmitterDID, since) 30 + if err != nil { 31 + return nil, err 32 + } 33 + if count >= MaxSuggestionsPerDay { 34 + return nil, ErrRateLimitExceeded 35 + } 36 + 37 + // Create the suggestion 38 + suggestion := &CommunitySuggestion{ 39 + Title: req.Title, 40 + Description: req.Description, 41 + SubmitterDID: req.SubmitterDID, 42 + Status: StatusOpen, 43 + } 44 + 45 + if err := s.repo.Create(ctx, suggestion); err != nil { 46 + return nil, err 47 + } 48 + 49 + return suggestion, nil 50 + } 51 + 52 + // GetSuggestion retrieves a suggestion by ID and populates viewer state if viewerDID is provided 53 + func (s *service) GetSuggestion(ctx context.Context, id int64, viewerDID string) (*CommunitySuggestion, error) { 54 + suggestion, err := s.repo.GetByID(ctx, id) 55 + if err != nil { 56 + return nil, err 57 + } 58 + 59 + // Populate viewer state if authenticated 60 + if viewerDID != "" { 61 + vote, err := s.repo.GetVote(ctx, id, viewerDID) 62 + if err != nil && !IsNotFound(err) { 63 + return nil, err 64 + } 65 + if vote != nil { 66 + suggestion.Viewer = &ViewerState{Vote: &vote.Value} 67 + } 68 + } 69 + 70 + return suggestion, nil 71 + } 72 + 73 + // ListSuggestions retrieves suggestions with filtering, sorting, and pagination 74 + // Populates viewer state for all returned suggestions if viewerDID is provided 75 + func (s *service) ListSuggestions(ctx context.Context, req ListSuggestionsRequest) ([]*CommunitySuggestion, error) { 76 + suggestions, err := s.repo.List(ctx, req) 77 + if err != nil { 78 + return nil, err 79 + } 80 + 81 + // Batch populate viewer state if authenticated 82 + if req.ViewerDID != "" && len(suggestions) > 0 { 83 + ids := make([]int64, len(suggestions)) 84 + for i, sg := range suggestions { 85 + ids[i] = sg.ID 86 + } 87 + 88 + votes, err := s.repo.GetVotesForViewer(ctx, req.ViewerDID, ids) 89 + if err != nil { 90 + return nil, err 91 + } 92 + 93 + for _, sg := range suggestions { 94 + if v, ok := votes[sg.ID]; ok { 95 + sg.Viewer = &ViewerState{Vote: &v} 96 + } 97 + } 98 + } 99 + 100 + return suggestions, nil 101 + } 102 + 103 + // Vote handles casting, toggling, and flipping votes on a suggestion 104 + // - If no existing vote: create a new vote 105 + // - If existing vote in the same direction: remove the vote (toggle off) 106 + // - If existing vote in the opposite direction: flip the vote 107 + func (s *service) Vote(ctx context.Context, req VoteRequest) error { 108 + if err := req.Validate(); err != nil { 109 + return err 110 + } 111 + return s.repo.AtomicVote(ctx, req.SuggestionID, req.VoterDID, req.Value) 112 + } 113 + 114 + // RemoveVote removes a user's vote from a suggestion 115 + func (s *service) RemoveVote(ctx context.Context, suggestionID int64, voterDID string) error { 116 + if suggestionID <= 0 { 117 + return ErrInvalidSuggestionID 118 + } 119 + if voterDID == "" { 120 + return ErrVoterRequired 121 + } 122 + 123 + _, err := s.repo.DeleteVote(ctx, suggestionID, voterDID) 124 + return err 125 + } 126 + 127 + // UpdateStatus validates the request and updates the suggestion's status 128 + func (s *service) UpdateStatus(ctx context.Context, req UpdateStatusRequest) error { 129 + if err := req.Validate(); err != nil { 130 + return err 131 + } 132 + return s.repo.UpdateStatus(ctx, req.SuggestionID, req.Status) 133 + }
+214
internal/core/communitysuggestions/suggestion.go
··· 1 + package communitysuggestions 2 + 3 + import ( 4 + "fmt" 5 + "strings" 6 + "time" 7 + "unicode/utf8" 8 + ) 9 + 10 + // Status represents the processing status of a community suggestion 11 + type Status string 12 + 13 + // Valid status values for community suggestions 14 + const ( 15 + StatusOpen Status = "open" 16 + StatusUnderReview Status = "under_review" 17 + StatusApproved Status = "approved" 18 + StatusDeclined Status = "declined" 19 + ) 20 + 21 + // MaxTitleLength is the maximum number of characters allowed in a suggestion title 22 + const MaxTitleLength = 200 23 + 24 + // MaxDescriptionLength is the maximum number of characters allowed in a suggestion description 25 + const MaxDescriptionLength = 5000 26 + 27 + // MaxSuggestionsPerDay is the maximum number of suggestions a single user can create per day 28 + const MaxSuggestionsPerDay = 3 29 + 30 + // ValidStatuses returns all valid status values 31 + func ValidStatuses() []Status { 32 + return []Status{StatusOpen, StatusUnderReview, StatusApproved, StatusDeclined} 33 + } 34 + 35 + // IsValidStatus checks if a status value is valid 36 + func IsValidStatus(status string) bool { 37 + for _, s := range ValidStatuses() { 38 + if string(s) == status { 39 + return true 40 + } 41 + } 42 + return false 43 + } 44 + 45 + // IsValidVoteValue checks if a vote value is valid (must be 1 or -1) 46 + func IsValidVoteValue(v int) bool { 47 + return v == 1 || v == -1 48 + } 49 + 50 + // CommunitySuggestion represents a community suggestion in the AppView database 51 + type CommunitySuggestion struct { 52 + ID int64 `json:"id" db:"id"` 53 + Title string `json:"title" db:"title"` 54 + Description string `json:"description" db:"description"` 55 + SubmitterDID string `json:"submitterDid" db:"submitter_did"` 56 + Status Status `json:"status" db:"status"` 57 + VoteCount int `json:"voteCount" db:"vote_count"` 58 + CreatedAt time.Time `json:"createdAt" db:"created_at"` 59 + UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` 60 + Viewer *ViewerState `json:"viewer,omitempty"` 61 + } 62 + 63 + // ViewerState contains information about the authenticated viewer's relationship 64 + // to a community suggestion (e.g., their vote) 65 + type ViewerState struct { 66 + Vote *int `json:"vote"` 67 + } 68 + 69 + // SuggestionVote represents a single vote on a community suggestion 70 + type SuggestionVote struct { 71 + ID int64 `json:"id" db:"id"` 72 + SuggestionID int64 `json:"suggestionId" db:"suggestion_id"` 73 + VoterDID string `json:"voterDid" db:"voter_did"` 74 + Value int `json:"value" db:"value"` 75 + CreatedAt time.Time `json:"createdAt" db:"created_at"` 76 + } 77 + 78 + // CreateSuggestionRequest contains the data needed to create a new community suggestion 79 + type CreateSuggestionRequest struct { 80 + // Title is the title of the community suggestion 81 + Title string 82 + 83 + // Description is a detailed description of the suggested community 84 + Description string 85 + 86 + // SubmitterDID is the DID of the user submitting the suggestion 87 + SubmitterDID string 88 + } 89 + 90 + // Validate validates the CreateSuggestionRequest and returns an error if invalid 91 + func (r *CreateSuggestionRequest) Validate() error { 92 + if r.SubmitterDID == "" { 93 + return ErrSubmitterRequired 94 + } 95 + 96 + r.Title = strings.TrimSpace(r.Title) 97 + if r.Title == "" { 98 + return ErrTitleRequired 99 + } 100 + if utf8.RuneCountInString(r.Title) > MaxTitleLength { 101 + return ErrTitleTooLong 102 + } 103 + 104 + r.Description = strings.TrimSpace(r.Description) 105 + if r.Description == "" { 106 + return ErrDescriptionRequired 107 + } 108 + if utf8.RuneCountInString(r.Description) > MaxDescriptionLength { 109 + return ErrDescriptionTooLong 110 + } 111 + 112 + return nil 113 + } 114 + 115 + // ListSuggestionsRequest contains the parameters for listing community suggestions 116 + type ListSuggestionsRequest struct { 117 + // Sort determines the ordering: "popular" (vote_count DESC) or "new" (created_at DESC) 118 + Sort string 119 + 120 + // Status optionally filters by suggestion status 121 + Status string 122 + 123 + // Limit is the maximum number of results to return 124 + Limit int 125 + 126 + // Offset is the number of results to skip for pagination 127 + Offset int 128 + 129 + // ViewerDID is the DID of the authenticated viewer (for populating viewer state) 130 + ViewerDID string 131 + } 132 + 133 + // Validate validates the ListSuggestionsRequest, applying defaults for missing values 134 + func (r *ListSuggestionsRequest) Validate() error { 135 + // Default/validate sort 136 + if r.Sort == "" { 137 + r.Sort = "popular" 138 + } 139 + if r.Sort != "popular" && r.Sort != "new" { 140 + return fmt.Errorf("invalid sort value: must be popular or new") 141 + } 142 + 143 + // Validate status if provided 144 + if r.Status != "" && !IsValidStatus(r.Status) { 145 + return ErrInvalidStatus 146 + } 147 + 148 + // Default/validate limit 149 + if r.Limit <= 0 { 150 + r.Limit = 50 151 + } 152 + if r.Limit > 100 { 153 + r.Limit = 100 154 + } 155 + 156 + // Validate offset 157 + if r.Offset < 0 { 158 + r.Offset = 0 159 + } 160 + 161 + return nil 162 + } 163 + 164 + // VoteRequest contains the data needed to cast a vote on a community suggestion 165 + type VoteRequest struct { 166 + // SuggestionID is the ID of the suggestion to vote on 167 + SuggestionID int64 168 + 169 + // VoterDID is the DID of the user casting the vote 170 + VoterDID string 171 + 172 + // Value is the vote value: 1 (upvote) or -1 (downvote) 173 + Value int 174 + } 175 + 176 + // Validate validates the VoteRequest and returns an error if invalid 177 + func (r *VoteRequest) Validate() error { 178 + if r.SuggestionID <= 0 { 179 + return ErrInvalidSuggestionID 180 + } 181 + if r.VoterDID == "" { 182 + return ErrVoterRequired 183 + } 184 + if !IsValidVoteValue(r.Value) { 185 + return ErrInvalidVoteValue 186 + } 187 + return nil 188 + } 189 + 190 + // UpdateStatusRequest contains the data needed to update a suggestion's status 191 + type UpdateStatusRequest struct { 192 + // SuggestionID is the ID of the suggestion to update 193 + SuggestionID int64 194 + 195 + // Status is the new status value 196 + Status Status 197 + 198 + // AdminDID is the DID of the admin performing the update 199 + AdminDID string 200 + } 201 + 202 + // Validate validates the UpdateStatusRequest and returns an error if invalid 203 + func (r *UpdateStatusRequest) Validate() error { 204 + if r.SuggestionID <= 0 { 205 + return ErrInvalidSuggestionID 206 + } 207 + if r.AdminDID == "" { 208 + return ErrNotAuthorized 209 + } 210 + if !IsValidStatus(string(r.Status)) { 211 + return ErrInvalidStatus 212 + } 213 + return nil 214 + }
+38
internal/db/migrations/030_create_community_suggestions.sql
··· 1 + -- +goose Up 2 + CREATE TABLE community_suggestions ( 3 + id BIGSERIAL PRIMARY KEY, 4 + title TEXT NOT NULL, 5 + description TEXT NOT NULL, 6 + submitter_did TEXT NOT NULL, 7 + status TEXT NOT NULL DEFAULT 'open', 8 + vote_count INTEGER NOT NULL DEFAULT 0, 9 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 10 + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 11 + CONSTRAINT valid_suggestion_status CHECK (status IN ('open', 'under_review', 'approved', 'declined')), 12 + CONSTRAINT title_not_empty CHECK (LENGTH(TRIM(title)) > 0), 13 + CONSTRAINT title_max_length CHECK (LENGTH(title) <= 200), 14 + CONSTRAINT description_max_length CHECK (LENGTH(description) <= 5000), 15 + CONSTRAINT description_not_empty CHECK (LENGTH(TRIM(description)) > 0) 16 + ); 17 + 18 + CREATE TABLE suggestion_votes ( 19 + id BIGSERIAL PRIMARY KEY, 20 + suggestion_id BIGINT NOT NULL REFERENCES community_suggestions(id) ON DELETE CASCADE, 21 + voter_did TEXT NOT NULL, 22 + value SMALLINT NOT NULL, 23 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 24 + CONSTRAINT valid_vote_value CHECK (value IN (1, -1)), 25 + CONSTRAINT unique_suggestion_voter UNIQUE (suggestion_id, voter_did) 26 + ); 27 + 28 + -- Indexes 29 + CREATE INDEX idx_suggestions_status ON community_suggestions(status); 30 + CREATE INDEX idx_suggestions_created_at ON community_suggestions(created_at DESC); 31 + CREATE INDEX idx_suggestions_vote_count ON community_suggestions(vote_count DESC); 32 + CREATE INDEX idx_suggestions_submitter ON community_suggestions(submitter_did); 33 + CREATE INDEX idx_suggestion_votes_suggestion ON suggestion_votes(suggestion_id); 34 + CREATE INDEX idx_suggestion_votes_voter ON suggestion_votes(voter_did); 35 + 36 + -- +goose Down 37 + DROP TABLE IF EXISTS suggestion_votes; 38 + DROP TABLE IF EXISTS community_suggestions;
+550
internal/db/postgres/community_suggestion_repo.go
··· 1 + package postgres 2 + 3 + import ( 4 + "Coves/internal/core/communitysuggestions" 5 + "context" 6 + "database/sql" 7 + "fmt" 8 + "log/slog" 9 + "strings" 10 + "time" 11 + 12 + "github.com/lib/pq" 13 + ) 14 + 15 + type postgresCommunitySuggestionRepo struct { 16 + db *sql.DB 17 + } 18 + 19 + // NewCommunitySuggestionRepository creates a new PostgreSQL community suggestion repository 20 + func NewCommunitySuggestionRepository(db *sql.DB) communitysuggestions.Repository { 21 + return &postgresCommunitySuggestionRepo{db: db} 22 + } 23 + 24 + // Create inserts a new community suggestion into the database 25 + // Returns the suggestion with ID, CreatedAt, and UpdatedAt populated 26 + func (r *postgresCommunitySuggestionRepo) Create(ctx context.Context, suggestion *communitysuggestions.CommunitySuggestion) error { 27 + query := ` 28 + INSERT INTO community_suggestions ( 29 + title, description, submitter_did, status 30 + ) VALUES ( 31 + $1, $2, $3, $4 32 + ) 33 + RETURNING id, created_at, updated_at 34 + ` 35 + 36 + status := suggestion.Status 37 + if status == "" { 38 + status = communitysuggestions.StatusOpen 39 + } 40 + 41 + err := r.db.QueryRowContext( 42 + ctx, query, 43 + suggestion.Title, suggestion.Description, 44 + suggestion.SubmitterDID, string(status), 45 + ).Scan(&suggestion.ID, &suggestion.CreatedAt, &suggestion.UpdatedAt) 46 + 47 + if err != nil { 48 + if pqErr := extractPQError(err); pqErr != nil { 49 + if strings.Contains(pqErr.Constraint, "valid_suggestion_status") { 50 + return communitysuggestions.ErrInvalidStatus 51 + } 52 + if strings.Contains(pqErr.Constraint, "title_not_empty") { 53 + return communitysuggestions.ErrTitleRequired 54 + } 55 + if strings.Contains(pqErr.Constraint, "title_max_length") { 56 + return communitysuggestions.ErrTitleTooLong 57 + } 58 + if strings.Contains(pqErr.Constraint, "description_not_empty") { 59 + return communitysuggestions.ErrDescriptionRequired 60 + } 61 + if strings.Contains(pqErr.Constraint, "description_max_length") { 62 + return communitysuggestions.ErrDescriptionTooLong 63 + } 64 + } 65 + return fmt.Errorf("failed to create community suggestion: %w", err) 66 + } 67 + 68 + suggestion.Status = status 69 + return nil 70 + } 71 + 72 + // GetByID retrieves a single community suggestion by its ID 73 + // Returns ErrSuggestionNotFound if the suggestion does not exist 74 + func (r *postgresCommunitySuggestionRepo) GetByID(ctx context.Context, id int64) (*communitysuggestions.CommunitySuggestion, error) { 75 + query := ` 76 + SELECT id, title, description, submitter_did, status, 77 + vote_count, created_at, updated_at 78 + FROM community_suggestions 79 + WHERE id = $1 80 + ` 81 + 82 + var suggestion communitysuggestions.CommunitySuggestion 83 + var status string 84 + 85 + err := r.db.QueryRowContext(ctx, query, id).Scan( 86 + &suggestion.ID, &suggestion.Title, &suggestion.Description, 87 + &suggestion.SubmitterDID, &status, 88 + &suggestion.VoteCount, &suggestion.CreatedAt, &suggestion.UpdatedAt, 89 + ) 90 + if err != nil { 91 + if err == sql.ErrNoRows { 92 + return nil, communitysuggestions.ErrSuggestionNotFound 93 + } 94 + return nil, fmt.Errorf("failed to get community suggestion by ID: %w", err) 95 + } 96 + 97 + suggestion.Status = communitysuggestions.Status(status) 98 + return &suggestion, nil 99 + } 100 + 101 + // List retrieves community suggestions with optional filtering and sorting 102 + // Supports sorting by "popular" (vote_count DESC, created_at DESC) or "new" (created_at DESC) 103 + // Supports optional filtering by status 104 + func (r *postgresCommunitySuggestionRepo) List(ctx context.Context, req communitysuggestions.ListSuggestionsRequest) ([]*communitysuggestions.CommunitySuggestion, error) { 105 + var queryBuilder strings.Builder 106 + var args []interface{} 107 + argIndex := 1 108 + 109 + queryBuilder.WriteString(` 110 + SELECT id, title, description, submitter_did, status, 111 + vote_count, created_at, updated_at 112 + FROM community_suggestions 113 + `) 114 + 115 + // Optional status filter 116 + if req.Status != "" { 117 + queryBuilder.WriteString(fmt.Sprintf(" WHERE status = $%d", argIndex)) 118 + args = append(args, req.Status) 119 + argIndex++ 120 + } 121 + 122 + // Sorting 123 + switch req.Sort { 124 + case "popular": 125 + queryBuilder.WriteString(" ORDER BY vote_count DESC, created_at DESC") 126 + case "new", "": 127 + queryBuilder.WriteString(" ORDER BY created_at DESC") 128 + default: 129 + return nil, fmt.Errorf("unknown sort value: %s", req.Sort) 130 + } 131 + 132 + // Pagination 133 + queryBuilder.WriteString(fmt.Sprintf(" LIMIT $%d OFFSET $%d", argIndex, argIndex+1)) 134 + limit := req.Limit 135 + if limit <= 0 { 136 + limit = 50 137 + } 138 + args = append(args, limit, req.Offset) 139 + 140 + rows, err := r.db.QueryContext(ctx, queryBuilder.String(), args...) 141 + if err != nil { 142 + return nil, fmt.Errorf("failed to list community suggestions: %w", err) 143 + } 144 + defer func() { 145 + if closeErr := rows.Close(); closeErr != nil { 146 + slog.Warn("failed to close rows in List community suggestions", 147 + slog.String("error", closeErr.Error()), 148 + ) 149 + } 150 + }() 151 + 152 + var suggestions []*communitysuggestions.CommunitySuggestion 153 + for rows.Next() { 154 + suggestion, err := scanSuggestion(rows) 155 + if err != nil { 156 + return nil, err 157 + } 158 + suggestions = append(suggestions, suggestion) 159 + } 160 + 161 + if err = rows.Err(); err != nil { 162 + return nil, fmt.Errorf("error iterating community suggestions: %w", err) 163 + } 164 + 165 + return suggestions, nil 166 + } 167 + 168 + // CountBySubmitterSince counts the number of suggestions created by a submitter since a given time 169 + // Used for rate limiting suggestion creation 170 + func (r *postgresCommunitySuggestionRepo) CountBySubmitterSince(ctx context.Context, submitterDID string, since time.Time) (int, error) { 171 + query := ` 172 + SELECT COUNT(*) 173 + FROM community_suggestions 174 + WHERE submitter_did = $1 AND created_at >= $2 175 + ` 176 + 177 + var count int 178 + err := r.db.QueryRowContext(ctx, query, submitterDID, since).Scan(&count) 179 + if err != nil { 180 + return 0, fmt.Errorf("failed to count suggestions by submitter: %w", err) 181 + } 182 + 183 + return count, nil 184 + } 185 + 186 + // UpdateStatus updates a suggestion's status 187 + // Returns ErrSuggestionNotFound if the suggestion does not exist 188 + func (r *postgresCommunitySuggestionRepo) UpdateStatus(ctx context.Context, id int64, status communitysuggestions.Status) error { 189 + query := ` 190 + UPDATE community_suggestions 191 + SET status = $1, updated_at = NOW() 192 + WHERE id = $2 193 + ` 194 + 195 + result, err := r.db.ExecContext(ctx, query, string(status), id) 196 + if err != nil { 197 + if pqErr := extractPQError(err); pqErr != nil { 198 + if strings.Contains(pqErr.Constraint, "valid_suggestion_status") { 199 + return communitysuggestions.ErrInvalidStatus 200 + } 201 + } 202 + return fmt.Errorf("failed to update community suggestion status: %w", err) 203 + } 204 + 205 + rowsAffected, err := result.RowsAffected() 206 + if err != nil { 207 + return fmt.Errorf("failed to check update result: %w", err) 208 + } 209 + 210 + if rowsAffected == 0 { 211 + return communitysuggestions.ErrSuggestionNotFound 212 + } 213 + 214 + return nil 215 + } 216 + 217 + // UpsertVote inserts or updates a vote for a suggestion and atomically updates the vote count 218 + // Returns the delta applied to the suggestion's vote count 219 + // Uses a transaction to ensure consistency between the vote and the denormalized count 220 + func (r *postgresCommunitySuggestionRepo) UpsertVote(ctx context.Context, suggestionID int64, voterDID string, value int) (int, error) { 221 + tx, err := r.db.BeginTx(ctx, nil) 222 + if err != nil { 223 + return 0, fmt.Errorf("failed to begin transaction for upsert vote: %w", err) 224 + } 225 + defer func() { 226 + if rbErr := tx.Rollback(); rbErr != nil && rbErr != sql.ErrTxDone { 227 + slog.Warn("failed to rollback upsert vote transaction", 228 + slog.String("error", rbErr.Error()), 229 + ) 230 + } 231 + }() 232 + 233 + // Check for existing vote with row lock 234 + var existingValue int 235 + var hasExisting bool 236 + selectQuery := ` 237 + SELECT value FROM suggestion_votes 238 + WHERE suggestion_id = $1 AND voter_did = $2 239 + FOR UPDATE 240 + ` 241 + err = tx.QueryRowContext(ctx, selectQuery, suggestionID, voterDID).Scan(&existingValue) 242 + if err != nil && err != sql.ErrNoRows { 243 + return 0, fmt.Errorf("failed to check existing vote: %w", err) 244 + } 245 + hasExisting = err == nil 246 + 247 + var delta int 248 + if hasExisting { 249 + // Update existing vote 250 + updateQuery := ` 251 + UPDATE suggestion_votes 252 + SET value = $1 253 + WHERE suggestion_id = $2 AND voter_did = $3 254 + ` 255 + _, err = tx.ExecContext(ctx, updateQuery, value, suggestionID, voterDID) 256 + if err != nil { 257 + return 0, fmt.Errorf("failed to update vote: %w", err) 258 + } 259 + // Delta is the difference between new and old value 260 + delta = value - existingValue 261 + } else { 262 + // Insert new vote 263 + insertQuery := ` 264 + INSERT INTO suggestion_votes (suggestion_id, voter_did, value) 265 + VALUES ($1, $2, $3) 266 + ` 267 + _, err = tx.ExecContext(ctx, insertQuery, suggestionID, voterDID, value) 268 + if err != nil { 269 + if pqErr := extractPQError(err); pqErr != nil { 270 + if strings.Contains(pqErr.Constraint, "valid_vote_value") { 271 + return 0, communitysuggestions.ErrInvalidVoteValue 272 + } 273 + } 274 + return 0, fmt.Errorf("failed to insert vote: %w", err) 275 + } 276 + delta = value 277 + } 278 + 279 + // Atomically update the denormalized vote count 280 + updateCountQuery := ` 281 + UPDATE community_suggestions 282 + SET vote_count = vote_count + $1, updated_at = NOW() 283 + WHERE id = $2 284 + ` 285 + _, err = tx.ExecContext(ctx, updateCountQuery, delta, suggestionID) 286 + if err != nil { 287 + return 0, fmt.Errorf("failed to update vote count: %w", err) 288 + } 289 + 290 + if err = tx.Commit(); err != nil { 291 + return 0, fmt.Errorf("failed to commit upsert vote transaction: %w", err) 292 + } 293 + 294 + return delta, nil 295 + } 296 + 297 + // DeleteVote removes a vote from a suggestion and atomically updates the vote count 298 + // Returns the delta applied to the suggestion's vote count 299 + // Uses a transaction to ensure consistency between the vote deletion and the denormalized count 300 + func (r *postgresCommunitySuggestionRepo) DeleteVote(ctx context.Context, suggestionID int64, voterDID string) (int, error) { 301 + tx, err := r.db.BeginTx(ctx, nil) 302 + if err != nil { 303 + return 0, fmt.Errorf("failed to begin transaction for delete vote: %w", err) 304 + } 305 + defer func() { 306 + if rbErr := tx.Rollback(); rbErr != nil && rbErr != sql.ErrTxDone { 307 + slog.Warn("failed to rollback delete vote transaction", 308 + slog.String("error", rbErr.Error()), 309 + ) 310 + } 311 + }() 312 + 313 + // Delete the vote and get the deleted value 314 + deleteQuery := ` 315 + DELETE FROM suggestion_votes 316 + WHERE suggestion_id = $1 AND voter_did = $2 317 + RETURNING value 318 + ` 319 + var deletedValue int 320 + err = tx.QueryRowContext(ctx, deleteQuery, suggestionID, voterDID).Scan(&deletedValue) 321 + if err != nil { 322 + if err == sql.ErrNoRows { 323 + return 0, communitysuggestions.ErrVoteNotFound 324 + } 325 + return 0, fmt.Errorf("failed to delete vote: %w", err) 326 + } 327 + 328 + // Atomically update the denormalized vote count (subtract the deleted vote value) 329 + delta := -deletedValue 330 + updateCountQuery := ` 331 + UPDATE community_suggestions 332 + SET vote_count = vote_count + $1, updated_at = NOW() 333 + WHERE id = $2 334 + ` 335 + result, err := tx.ExecContext(ctx, updateCountQuery, delta, suggestionID) 336 + if err != nil { 337 + return 0, fmt.Errorf("failed to update vote count after delete: %w", err) 338 + } 339 + rowsAffected, err := result.RowsAffected() 340 + if err != nil { 341 + return 0, fmt.Errorf("failed to check vote count update result: %w", err) 342 + } 343 + if rowsAffected == 0 { 344 + return 0, communitysuggestions.ErrSuggestionNotFound 345 + } 346 + 347 + if err = tx.Commit(); err != nil { 348 + return 0, fmt.Errorf("failed to commit delete vote transaction: %w", err) 349 + } 350 + 351 + return delta, nil 352 + } 353 + 354 + // AtomicVote atomically handles voting with toggle semantics in a single transaction 355 + // If no existing vote: creates the vote 356 + // If existing vote in same direction: removes the vote (toggle off) 357 + // If existing vote in opposite direction: flips the vote 358 + func (r *postgresCommunitySuggestionRepo) AtomicVote(ctx context.Context, suggestionID int64, voterDID string, value int) error { 359 + tx, err := r.db.BeginTx(ctx, nil) 360 + if err != nil { 361 + return fmt.Errorf("failed to begin transaction for atomic vote: %w", err) 362 + } 363 + defer func() { 364 + if rbErr := tx.Rollback(); rbErr != nil && rbErr != sql.ErrTxDone { 365 + slog.Warn("failed to rollback atomic vote transaction", 366 + slog.String("error", rbErr.Error()), 367 + ) 368 + } 369 + }() 370 + 371 + // Verify suggestion exists and lock the row to prevent concurrent modification 372 + var exists bool 373 + existsQuery := `SELECT EXISTS(SELECT 1 FROM community_suggestions WHERE id = $1 FOR UPDATE)` 374 + err = tx.QueryRowContext(ctx, existsQuery, suggestionID).Scan(&exists) 375 + if err != nil { 376 + return fmt.Errorf("failed to check suggestion existence: %w", err) 377 + } 378 + if !exists { 379 + return communitysuggestions.ErrSuggestionNotFound 380 + } 381 + 382 + // Check for existing vote with row lock 383 + var existingValue int 384 + var hasExisting bool 385 + selectQuery := ` 386 + SELECT value FROM suggestion_votes 387 + WHERE suggestion_id = $1 AND voter_did = $2 388 + FOR UPDATE 389 + ` 390 + err = tx.QueryRowContext(ctx, selectQuery, suggestionID, voterDID).Scan(&existingValue) 391 + if err != nil && err != sql.ErrNoRows { 392 + return fmt.Errorf("failed to check existing vote: %w", err) 393 + } 394 + hasExisting = err == nil 395 + 396 + var delta int 397 + if hasExisting { 398 + if existingValue == value { 399 + // Same direction: toggle off (remove the vote) 400 + deleteQuery := ` 401 + DELETE FROM suggestion_votes 402 + WHERE suggestion_id = $1 AND voter_did = $2 403 + ` 404 + _, err = tx.ExecContext(ctx, deleteQuery, suggestionID, voterDID) 405 + if err != nil { 406 + return fmt.Errorf("failed to delete vote during toggle: %w", err) 407 + } 408 + delta = -existingValue 409 + } else { 410 + // Opposite direction: flip the vote 411 + updateQuery := ` 412 + UPDATE suggestion_votes 413 + SET value = $1 414 + WHERE suggestion_id = $2 AND voter_did = $3 415 + ` 416 + _, err = tx.ExecContext(ctx, updateQuery, value, suggestionID, voterDID) 417 + if err != nil { 418 + return fmt.Errorf("failed to update vote during flip: %w", err) 419 + } 420 + delta = value - existingValue 421 + } 422 + } else { 423 + // No existing vote: insert new 424 + insertQuery := ` 425 + INSERT INTO suggestion_votes (suggestion_id, voter_did, value) 426 + VALUES ($1, $2, $3) 427 + ` 428 + _, err = tx.ExecContext(ctx, insertQuery, suggestionID, voterDID, value) 429 + if err != nil { 430 + if pqErr := extractPQError(err); pqErr != nil { 431 + if pqErr.Code == "23503" { 432 + return communitysuggestions.ErrSuggestionNotFound 433 + } 434 + if strings.Contains(pqErr.Constraint, "valid_vote_value") { 435 + return communitysuggestions.ErrInvalidVoteValue 436 + } 437 + } 438 + return fmt.Errorf("failed to insert vote: %w", err) 439 + } 440 + delta = value 441 + } 442 + 443 + // Update the denormalized vote count and verify the suggestion still exists 444 + updateCountQuery := ` 445 + UPDATE community_suggestions 446 + SET vote_count = vote_count + $1, updated_at = NOW() 447 + WHERE id = $2 448 + ` 449 + result, err := tx.ExecContext(ctx, updateCountQuery, delta, suggestionID) 450 + if err != nil { 451 + return fmt.Errorf("failed to update vote count: %w", err) 452 + } 453 + rowsAffected, err := result.RowsAffected() 454 + if err != nil { 455 + return fmt.Errorf("failed to check vote count update result: %w", err) 456 + } 457 + if rowsAffected == 0 { 458 + return communitysuggestions.ErrSuggestionNotFound 459 + } 460 + 461 + if err = tx.Commit(); err != nil { 462 + return fmt.Errorf("failed to commit atomic vote transaction: %w", err) 463 + } 464 + 465 + return nil 466 + } 467 + 468 + // GetVote retrieves a single vote by suggestion ID and voter DID 469 + // Returns ErrVoteNotFound if the vote does not exist 470 + func (r *postgresCommunitySuggestionRepo) GetVote(ctx context.Context, suggestionID int64, voterDID string) (*communitysuggestions.SuggestionVote, error) { 471 + query := ` 472 + SELECT id, suggestion_id, voter_did, value, created_at 473 + FROM suggestion_votes 474 + WHERE suggestion_id = $1 AND voter_did = $2 475 + ` 476 + 477 + var vote communitysuggestions.SuggestionVote 478 + err := r.db.QueryRowContext(ctx, query, suggestionID, voterDID).Scan( 479 + &vote.ID, &vote.SuggestionID, &vote.VoterDID, 480 + &vote.Value, &vote.CreatedAt, 481 + ) 482 + if err != nil { 483 + if err == sql.ErrNoRows { 484 + return nil, communitysuggestions.ErrVoteNotFound 485 + } 486 + return nil, fmt.Errorf("failed to get vote: %w", err) 487 + } 488 + 489 + return &vote, nil 490 + } 491 + 492 + // GetVotesForViewer retrieves the votes cast by a viewer on a set of suggestions 493 + // Returns a map of suggestion ID to vote value 494 + func (r *postgresCommunitySuggestionRepo) GetVotesForViewer(ctx context.Context, voterDID string, suggestionIDs []int64) (map[int64]int, error) { 495 + if len(suggestionIDs) == 0 { 496 + return make(map[int64]int), nil 497 + } 498 + 499 + query := ` 500 + SELECT suggestion_id, value 501 + FROM suggestion_votes 502 + WHERE voter_did = $1 AND suggestion_id = ANY($2) 503 + ` 504 + 505 + rows, err := r.db.QueryContext(ctx, query, voterDID, pq.Array(suggestionIDs)) 506 + if err != nil { 507 + return nil, fmt.Errorf("failed to get votes for viewer: %w", err) 508 + } 509 + defer func() { 510 + if closeErr := rows.Close(); closeErr != nil { 511 + slog.Warn("failed to close rows in GetVotesForViewer", 512 + slog.String("error", closeErr.Error()), 513 + ) 514 + } 515 + }() 516 + 517 + votes := make(map[int64]int) 518 + for rows.Next() { 519 + var suggestionID int64 520 + var value int 521 + if err := rows.Scan(&suggestionID, &value); err != nil { 522 + return nil, fmt.Errorf("failed to scan vote for viewer: %w", err) 523 + } 524 + votes[suggestionID] = value 525 + } 526 + 527 + if err = rows.Err(); err != nil { 528 + return nil, fmt.Errorf("error iterating votes for viewer: %w", err) 529 + } 530 + 531 + return votes, nil 532 + } 533 + 534 + // scanSuggestion scans a single suggestion from a database row 535 + func scanSuggestion(rows *sql.Rows) (*communitysuggestions.CommunitySuggestion, error) { 536 + var suggestion communitysuggestions.CommunitySuggestion 537 + var status string 538 + 539 + err := rows.Scan( 540 + &suggestion.ID, &suggestion.Title, &suggestion.Description, 541 + &suggestion.SubmitterDID, &status, 542 + &suggestion.VoteCount, &suggestion.CreatedAt, &suggestion.UpdatedAt, 543 + ) 544 + if err != nil { 545 + return nil, fmt.Errorf("failed to scan community suggestion: %w", err) 546 + } 547 + 548 + suggestion.Status = communitysuggestions.Status(status) 549 + return &suggestion, nil 550 + }
+2
scripts/setup-mobile-ports.sh
··· 35 35 adb reverse tcp:3000 tcp:3001 # PDS (internal port in DID document) 36 36 adb reverse tcp:3001 tcp:3001 # PDS (external port) 37 37 adb reverse tcp:3002 tcp:3002 # PLC Directory 38 + adb reverse tcp:8080 tcp:8080 # Caddy proxy (OAuth callbacks route through here) 38 39 adb reverse tcp:8081 tcp:8081 # AppView 39 40 40 41 echo "" ··· 47 48 echo -e "${GREEN}PDS (3000):${NC} localhost:3001 → device:3000 ${YELLOW}(DID document port)${NC}" 48 49 echo -e "${GREEN}PDS (3001):${NC} localhost:3001 → device:3001" 49 50 echo -e "${GREEN}PLC (3002):${NC} localhost:3002 → device:3002" 51 + echo -e "${GREEN}Caddy (8080):${NC} localhost:8080 → device:8080 ${YELLOW}(OAuth callbacks)${NC}" 50 52 echo -e "${GREEN}AppView (8081):${NC} localhost:8081 → device:8081" 51 53 echo "" 52 54 echo -e "${CYAN}═══════════════════════════════════════════════════════════${NC}"
+1268
tests/integration/community_suggestion_e2e_test.go
··· 1 + package integration 2 + 3 + import ( 4 + "Coves/internal/api/routes" 5 + "Coves/internal/core/communitysuggestions" 6 + "Coves/internal/db/postgres" 7 + "bytes" 8 + "encoding/json" 9 + "fmt" 10 + "net/http" 11 + "net/http/httptest" 12 + "strings" 13 + "testing" 14 + "time" 15 + 16 + "github.com/go-chi/chi/v5" 17 + _ "github.com/lib/pq" 18 + ) 19 + 20 + // --- Test helpers --- 21 + 22 + // suggestionResponse represents the JSON response for a single community suggestion 23 + type suggestionResponse struct { 24 + ID int64 `json:"id"` 25 + Title string `json:"title"` 26 + Description string `json:"description"` 27 + SubmitterDID string `json:"submitterDid"` 28 + Status string `json:"status"` 29 + VoteCount int `json:"voteCount"` 30 + CreatedAt string `json:"createdAt"` 31 + UpdatedAt string `json:"updatedAt"` 32 + Viewer *struct { 33 + Vote *int `json:"vote"` 34 + } `json:"viewer"` 35 + } 36 + 37 + // listSuggestionsResponse represents the JSON response for listing suggestions 38 + type listSuggestionsResponse struct { 39 + Suggestions []suggestionResponse `json:"suggestions"` 40 + Cursor string `json:"cursor"` 41 + } 42 + 43 + // xrpcErrorResponse represents an XRPC error response 44 + type xrpcErrorResponse struct { 45 + Error string `json:"error"` 46 + Message string `json:"message"` 47 + } 48 + 49 + // createTestSuggestionRequest creates a suggestion via the HTTP API and returns the response recorder. 50 + // Does NOT fail the test on non-200 responses so callers can assert specific error codes. 51 + func createTestSuggestionRequest(t *testing.T, router http.Handler, token, title, description string) *httptest.ResponseRecorder { 52 + t.Helper() 53 + 54 + body, err := json.Marshal(map[string]string{ 55 + "title": title, 56 + "description": description, 57 + }) 58 + if err != nil { 59 + t.Fatalf("Failed to marshal create suggestion request: %v", err) 60 + } 61 + 62 + req := httptest.NewRequest(http.MethodPost, 63 + "/xrpc/social.coves.community.suggestion.create", 64 + bytes.NewBuffer(body)) 65 + req.Header.Set("Content-Type", "application/json") 66 + if token != "" { 67 + req.Header.Set("Authorization", "Bearer "+token) 68 + } 69 + 70 + rec := httptest.NewRecorder() 71 + router.ServeHTTP(rec, req) 72 + return rec 73 + } 74 + 75 + // mustCreateTestSuggestion creates a suggestion and fails the test if it doesn't succeed. 76 + // Returns the decoded suggestion response. 77 + func mustCreateTestSuggestion(t *testing.T, router http.Handler, token, title, description string) suggestionResponse { 78 + t.Helper() 79 + 80 + rec := createTestSuggestionRequest(t, router, token, title, description) 81 + if rec.Code != http.StatusOK { 82 + t.Fatalf("Expected 200 creating suggestion, got %d: %s", rec.Code, rec.Body.String()) 83 + } 84 + 85 + var resp suggestionResponse 86 + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { 87 + t.Fatalf("Failed to decode create suggestion response: %v", err) 88 + } 89 + return resp 90 + } 91 + 92 + // voteOnSuggestionRequest casts a vote via the HTTP API and returns the response recorder. 93 + func voteOnSuggestionRequest(t *testing.T, router http.Handler, token string, suggestionID int64, value int) *httptest.ResponseRecorder { 94 + t.Helper() 95 + 96 + body, err := json.Marshal(map[string]interface{}{ 97 + "suggestionId": suggestionID, 98 + "value": value, 99 + }) 100 + if err != nil { 101 + t.Fatalf("Failed to marshal vote request: %v", err) 102 + } 103 + 104 + req := httptest.NewRequest(http.MethodPost, 105 + "/xrpc/social.coves.community.suggestion.vote", 106 + bytes.NewBuffer(body)) 107 + req.Header.Set("Content-Type", "application/json") 108 + if token != "" { 109 + req.Header.Set("Authorization", "Bearer "+token) 110 + } 111 + 112 + rec := httptest.NewRecorder() 113 + router.ServeHTTP(rec, req) 114 + return rec 115 + } 116 + 117 + // removeVoteRequest removes a vote via the HTTP API and returns the response recorder. 118 + func removeVoteRequest(t *testing.T, router http.Handler, token string, suggestionID int64) *httptest.ResponseRecorder { 119 + t.Helper() 120 + 121 + body, err := json.Marshal(map[string]interface{}{ 122 + "suggestionId": suggestionID, 123 + }) 124 + if err != nil { 125 + t.Fatalf("Failed to marshal remove vote request: %v", err) 126 + } 127 + 128 + req := httptest.NewRequest(http.MethodPost, 129 + "/xrpc/social.coves.community.suggestion.removeVote", 130 + bytes.NewBuffer(body)) 131 + req.Header.Set("Content-Type", "application/json") 132 + if token != "" { 133 + req.Header.Set("Authorization", "Bearer "+token) 134 + } 135 + 136 + rec := httptest.NewRecorder() 137 + router.ServeHTTP(rec, req) 138 + return rec 139 + } 140 + 141 + // getSuggestionRequest fetches a suggestion by ID via the HTTP API. 142 + func getSuggestionRequest(t *testing.T, router http.Handler, token string, id int64) *httptest.ResponseRecorder { 143 + t.Helper() 144 + 145 + url := fmt.Sprintf("/xrpc/social.coves.community.suggestion.get?id=%d", id) 146 + req := httptest.NewRequest(http.MethodGet, url, nil) 147 + if token != "" { 148 + req.Header.Set("Authorization", "Bearer "+token) 149 + } 150 + 151 + rec := httptest.NewRecorder() 152 + router.ServeHTTP(rec, req) 153 + return rec 154 + } 155 + 156 + // listSuggestionsRequest lists suggestions via the HTTP API with query params. 157 + func listSuggestionsRequest(t *testing.T, router http.Handler, token string, queryParams string) *httptest.ResponseRecorder { 158 + t.Helper() 159 + 160 + url := "/xrpc/social.coves.community.suggestion.list" 161 + if queryParams != "" { 162 + url += "?" + queryParams 163 + } 164 + 165 + req := httptest.NewRequest(http.MethodGet, url, nil) 166 + if token != "" { 167 + req.Header.Set("Authorization", "Bearer "+token) 168 + } 169 + 170 + rec := httptest.NewRecorder() 171 + router.ServeHTTP(rec, req) 172 + return rec 173 + } 174 + 175 + // updateStatusRequest updates a suggestion's status via the HTTP API. 176 + func updateStatusRequest(t *testing.T, router http.Handler, token string, suggestionID int64, status string) *httptest.ResponseRecorder { 177 + t.Helper() 178 + 179 + body, err := json.Marshal(map[string]interface{}{ 180 + "suggestionId": suggestionID, 181 + "status": status, 182 + }) 183 + if err != nil { 184 + t.Fatalf("Failed to marshal update status request: %v", err) 185 + } 186 + 187 + req := httptest.NewRequest(http.MethodPost, 188 + "/xrpc/social.coves.community.suggestion.updateStatus", 189 + bytes.NewBuffer(body)) 190 + req.Header.Set("Content-Type", "application/json") 191 + if token != "" { 192 + req.Header.Set("Authorization", "Bearer "+token) 193 + } 194 + 195 + rec := httptest.NewRecorder() 196 + router.ServeHTTP(rec, req) 197 + return rec 198 + } 199 + 200 + // setupSuggestionTestRouter sets up a chi router with real community suggestion handlers, 201 + // a real PostgreSQL repository, and a mock OAuth middleware for authentication injection. 202 + // Returns the router, the E2EOAuthMiddleware (for adding users), and a cleanup function. 203 + func setupSuggestionTestRouter(t *testing.T, adminDIDs []string) (http.Handler, *E2EOAuthMiddleware) { 204 + t.Helper() 205 + 206 + db := setupTestDB(t) 207 + 208 + // Clean up suggestion-specific tables at the start to avoid dirty state from previous runs 209 + if _, err := db.Exec("DELETE FROM suggestion_votes"); err != nil { 210 + t.Logf("Warning: Failed to clean up suggestion_votes: %v", err) 211 + } 212 + if _, err := db.Exec("DELETE FROM community_suggestions"); err != nil { 213 + t.Logf("Warning: Failed to clean up community_suggestions: %v", err) 214 + } 215 + 216 + t.Cleanup(func() { 217 + // Clean up at end too to leave DB clean 218 + _, _ = db.Exec("DELETE FROM suggestion_votes") 219 + _, _ = db.Exec("DELETE FROM community_suggestions") 220 + _ = db.Close() 221 + }) 222 + 223 + // Wire up real repository and service 224 + repo := postgres.NewCommunitySuggestionRepository(db) 225 + service := communitysuggestions.NewService(repo) 226 + 227 + // Create E2E OAuth middleware for injecting test users 228 + e2eAuth := NewE2EOAuthMiddleware() 229 + 230 + // Set up chi router with real handlers via route registration 231 + r := chi.NewRouter() 232 + routes.RegisterCommunitySuggestionRoutes(r, service, e2eAuth.OAuthAuthMiddleware, adminDIDs) 233 + 234 + return r, e2eAuth 235 + } 236 + 237 + // TestCommunitySuggestionE2E is the comprehensive E2E integration test for the 238 + // Community Suggestions & Voting feature. It tests the full stack: 239 + // HTTP handlers -> service -> repository -> PostgreSQL. 240 + func TestCommunitySuggestionE2E(t *testing.T) { 241 + if testing.Short() { 242 + t.Skip("Skipping E2E test in short mode") 243 + } 244 + 245 + adminDID := "did:plc:testadmin" 246 + userDID := "did:plc:testuser1" 247 + user2DID := "did:plc:testuser2" 248 + user3DID := "did:plc:testuser3" 249 + 250 + router, e2eAuth := setupSuggestionTestRouter(t, []string{adminDID}) 251 + 252 + // Register test users with the mock auth system 253 + _ = e2eAuth.AddUser(adminDID) // admin registered for shared router 254 + userToken := e2eAuth.AddUser(userDID) 255 + user2Token := e2eAuth.AddUser(user2DID) 256 + _ = e2eAuth.AddUser(user3DID) // registered but used in subtests with own routers 257 + 258 + // ===================================================================== 259 + // Test: Create Suggestion 260 + // ===================================================================== 261 + t.Run("Create suggestion", func(t *testing.T) { 262 + rec := createTestSuggestionRequest(t, router, userToken, "Golang Community", "A place for Go developers to share and learn") 263 + if rec.Code != http.StatusOK { 264 + t.Fatalf("Expected 200, got %d: %s", rec.Code, rec.Body.String()) 265 + } 266 + 267 + var resp suggestionResponse 268 + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { 269 + t.Fatalf("Failed to decode response: %v", err) 270 + } 271 + 272 + if resp.ID <= 0 { 273 + t.Errorf("Expected positive ID, got %d", resp.ID) 274 + } 275 + if resp.Title != "Golang Community" { 276 + t.Errorf("Expected title 'Golang Community', got %q", resp.Title) 277 + } 278 + if resp.Description != "A place for Go developers to share and learn" { 279 + t.Errorf("Expected description to match, got %q", resp.Description) 280 + } 281 + if resp.Status != "open" { 282 + t.Errorf("Expected status 'open', got %q", resp.Status) 283 + } 284 + if resp.VoteCount != 0 { 285 + t.Errorf("Expected voteCount 0, got %d", resp.VoteCount) 286 + } 287 + if resp.SubmitterDID != userDID { 288 + t.Errorf("Expected submitterDid %q, got %q", userDID, resp.SubmitterDID) 289 + } 290 + if resp.CreatedAt == "" { 291 + t.Error("Expected createdAt to be populated") 292 + } 293 + if resp.UpdatedAt == "" { 294 + t.Error("Expected updatedAt to be populated") 295 + } 296 + }) 297 + 298 + // ===================================================================== 299 + // Test: Create Suggestion - Validation Errors 300 + // ===================================================================== 301 + t.Run("Create suggestion - missing title", func(t *testing.T) { 302 + rec := createTestSuggestionRequest(t, router, userToken, "", "Some description") 303 + if rec.Code != http.StatusBadRequest { 304 + t.Fatalf("Expected 400, got %d: %s", rec.Code, rec.Body.String()) 305 + } 306 + 307 + var errResp xrpcErrorResponse 308 + if err := json.NewDecoder(rec.Body).Decode(&errResp); err != nil { 309 + t.Fatalf("Failed to decode error response: %v", err) 310 + } 311 + if errResp.Error != "InvalidRequest" { 312 + t.Errorf("Expected error 'InvalidRequest', got %q", errResp.Error) 313 + } 314 + }) 315 + 316 + t.Run("Create suggestion - empty description", func(t *testing.T) { 317 + rec := createTestSuggestionRequest(t, router, userToken, "Valid Title", "") 318 + if rec.Code != http.StatusBadRequest { 319 + t.Fatalf("Expected 400, got %d: %s", rec.Code, rec.Body.String()) 320 + } 321 + }) 322 + 323 + t.Run("Create suggestion - title too long", func(t *testing.T) { 324 + longTitle := strings.Repeat("a", communitysuggestions.MaxTitleLength+1) 325 + rec := createTestSuggestionRequest(t, router, userToken, longTitle, "Valid description") 326 + if rec.Code != http.StatusBadRequest { 327 + t.Fatalf("Expected 400, got %d: %s", rec.Code, rec.Body.String()) 328 + } 329 + 330 + var errResp xrpcErrorResponse 331 + if err := json.NewDecoder(rec.Body).Decode(&errResp); err != nil { 332 + t.Fatalf("Failed to decode error response: %v", err) 333 + } 334 + if errResp.Error != "InvalidRequest" { 335 + t.Errorf("Expected error 'InvalidRequest', got %q", errResp.Error) 336 + } 337 + }) 338 + 339 + // ===================================================================== 340 + // Test: Create Suggestion - Auth Required 341 + // ===================================================================== 342 + t.Run("Create suggestion - auth required", func(t *testing.T) { 343 + rec := createTestSuggestionRequest(t, router, "", "No Auth", "Should fail") 344 + if rec.Code != http.StatusUnauthorized { 345 + t.Fatalf("Expected 401, got %d: %s", rec.Code, rec.Body.String()) 346 + } 347 + }) 348 + 349 + // ===================================================================== 350 + // Test: Get Suggestion by ID 351 + // ===================================================================== 352 + t.Run("Get suggestion by ID", func(t *testing.T) { 353 + // Create a suggestion first 354 + created := mustCreateTestSuggestion(t, router, user2Token, 355 + "Get Test Community", "Community to test get endpoint") 356 + 357 + rec := getSuggestionRequest(t, router, user2Token, created.ID) 358 + if rec.Code != http.StatusOK { 359 + t.Fatalf("Expected 200, got %d: %s", rec.Code, rec.Body.String()) 360 + } 361 + 362 + var resp suggestionResponse 363 + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { 364 + t.Fatalf("Failed to decode response: %v", err) 365 + } 366 + 367 + if resp.ID != created.ID { 368 + t.Errorf("Expected ID %d, got %d", created.ID, resp.ID) 369 + } 370 + if resp.Title != "Get Test Community" { 371 + t.Errorf("Expected title 'Get Test Community', got %q", resp.Title) 372 + } 373 + if resp.Description != "Community to test get endpoint" { 374 + t.Errorf("Expected description to match, got %q", resp.Description) 375 + } 376 + if resp.Status != "open" { 377 + t.Errorf("Expected status 'open', got %q", resp.Status) 378 + } 379 + }) 380 + 381 + // ===================================================================== 382 + // Test: Get Suggestion - Not Found 383 + // ===================================================================== 384 + t.Run("Get suggestion - not found", func(t *testing.T) { 385 + rec := getSuggestionRequest(t, router, userToken, 999999) 386 + if rec.Code != http.StatusNotFound { 387 + t.Fatalf("Expected 404, got %d: %s", rec.Code, rec.Body.String()) 388 + } 389 + 390 + var errResp xrpcErrorResponse 391 + if err := json.NewDecoder(rec.Body).Decode(&errResp); err != nil { 392 + t.Fatalf("Failed to decode error response: %v", err) 393 + } 394 + if errResp.Error != "NotFound" { 395 + t.Errorf("Expected error 'NotFound', got %q", errResp.Error) 396 + } 397 + }) 398 + 399 + // ===================================================================== 400 + // Test: List Suggestions - Default Sort (Popular) 401 + // ===================================================================== 402 + t.Run("List suggestions - default sort popular", func(t *testing.T) { 403 + // Create a fresh router to avoid pollution from previous tests 404 + listRouter, listAuth := setupSuggestionTestRouter(t, []string{adminDID}) 405 + listUserToken := listAuth.AddUser(userDID) 406 + listUser2Token := listAuth.AddUser(user2DID) 407 + 408 + // Create two suggestions 409 + s1 := mustCreateTestSuggestion(t, listRouter, listUserToken, 410 + "Popular Community", "Should be sorted by votes") 411 + s2 := mustCreateTestSuggestion(t, listRouter, listUserToken, 412 + "Less Popular Community", "Should appear after popular") 413 + 414 + // Vote on the first suggestion to make it more popular 415 + voteRec := voteOnSuggestionRequest(t, listRouter, listUser2Token, s1.ID, 1) 416 + if voteRec.Code != http.StatusOK { 417 + t.Fatalf("Vote failed: %d: %s", voteRec.Code, voteRec.Body.String()) 418 + } 419 + 420 + // List with default sort (popular) 421 + rec := listSuggestionsRequest(t, listRouter, listUserToken, "") 422 + if rec.Code != http.StatusOK { 423 + t.Fatalf("Expected 200, got %d: %s", rec.Code, rec.Body.String()) 424 + } 425 + 426 + var resp listSuggestionsResponse 427 + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { 428 + t.Fatalf("Failed to decode response: %v", err) 429 + } 430 + 431 + if len(resp.Suggestions) < 2 { 432 + t.Fatalf("Expected at least 2 suggestions, got %d", len(resp.Suggestions)) 433 + } 434 + 435 + // First suggestion should be the one with votes (s1) 436 + if resp.Suggestions[0].ID != s1.ID { 437 + t.Errorf("Expected first suggestion ID %d (popular), got %d", s1.ID, resp.Suggestions[0].ID) 438 + } 439 + if resp.Suggestions[0].VoteCount != 1 { 440 + t.Errorf("Expected first suggestion voteCount 1, got %d", resp.Suggestions[0].VoteCount) 441 + } 442 + 443 + // Second suggestion should be the one without votes (s2) 444 + found := false 445 + for _, sg := range resp.Suggestions { 446 + if sg.ID == s2.ID { 447 + found = true 448 + if sg.VoteCount != 0 { 449 + t.Errorf("Expected s2 voteCount 0, got %d", sg.VoteCount) 450 + } 451 + break 452 + } 453 + } 454 + if !found { 455 + t.Error("Expected to find s2 in list results") 456 + } 457 + }) 458 + 459 + // ===================================================================== 460 + // Test: List Suggestions - Sort by New 461 + // ===================================================================== 462 + t.Run("List suggestions - sort by new", func(t *testing.T) { 463 + listRouter, listAuth := setupSuggestionTestRouter(t, []string{adminDID}) 464 + listUserToken := listAuth.AddUser(userDID) 465 + 466 + // Create two suggestions with a small delay to ensure different timestamps 467 + _ = mustCreateTestSuggestion(t, listRouter, listUserToken, 468 + "Older Community", "Created first") 469 + time.Sleep(10 * time.Millisecond) 470 + s2 := mustCreateTestSuggestion(t, listRouter, listUserToken, 471 + "Newer Community", "Created second") 472 + 473 + rec := listSuggestionsRequest(t, listRouter, listUserToken, "sort=new") 474 + if rec.Code != http.StatusOK { 475 + t.Fatalf("Expected 200, got %d: %s", rec.Code, rec.Body.String()) 476 + } 477 + 478 + var resp listSuggestionsResponse 479 + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { 480 + t.Fatalf("Failed to decode response: %v", err) 481 + } 482 + 483 + if len(resp.Suggestions) < 2 { 484 + t.Fatalf("Expected at least 2 suggestions, got %d", len(resp.Suggestions)) 485 + } 486 + 487 + // First suggestion should be the newest (s2) 488 + if resp.Suggestions[0].ID != s2.ID { 489 + t.Errorf("Expected first suggestion ID %d (newest), got %d", s2.ID, resp.Suggestions[0].ID) 490 + } 491 + }) 492 + 493 + // ===================================================================== 494 + // Test: List Suggestions - Status Filter 495 + // ===================================================================== 496 + t.Run("List suggestions - status filter", func(t *testing.T) { 497 + listRouter, listAuth := setupSuggestionTestRouter(t, []string{adminDID}) 498 + listUserToken := listAuth.AddUser(userDID) 499 + listAdminToken := listAuth.AddUser(adminDID) 500 + 501 + // Create two suggestions 502 + s1 := mustCreateTestSuggestion(t, listRouter, listUserToken, 503 + "Open Suggestion", "Should remain open") 504 + _ = mustCreateTestSuggestion(t, listRouter, listUserToken, 505 + "To Be Approved", "Will be updated to approved") 506 + 507 + // Update s1 status to approved via admin 508 + statusRec := updateStatusRequest(t, listRouter, listAdminToken, s1.ID, "approved") 509 + if statusRec.Code != http.StatusOK { 510 + t.Fatalf("Status update failed: %d: %s", statusRec.Code, statusRec.Body.String()) 511 + } 512 + 513 + // List only approved suggestions 514 + rec := listSuggestionsRequest(t, listRouter, listUserToken, "status=approved") 515 + if rec.Code != http.StatusOK { 516 + t.Fatalf("Expected 200, got %d: %s", rec.Code, rec.Body.String()) 517 + } 518 + 519 + var resp listSuggestionsResponse 520 + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { 521 + t.Fatalf("Failed to decode response: %v", err) 522 + } 523 + 524 + // Should only contain approved suggestions 525 + for _, sg := range resp.Suggestions { 526 + if sg.Status != "approved" { 527 + t.Errorf("Expected all suggestions to have status 'approved', got %q for ID %d", sg.Status, sg.ID) 528 + } 529 + } 530 + 531 + // Should have at least 1 result 532 + if len(resp.Suggestions) == 0 { 533 + t.Error("Expected at least 1 approved suggestion") 534 + } 535 + 536 + // List only open suggestions 537 + rec2 := listSuggestionsRequest(t, listRouter, listUserToken, "status=open") 538 + if rec2.Code != http.StatusOK { 539 + t.Fatalf("Expected 200, got %d: %s", rec2.Code, rec2.Body.String()) 540 + } 541 + 542 + var resp2 listSuggestionsResponse 543 + if err := json.NewDecoder(rec2.Body).Decode(&resp2); err != nil { 544 + t.Fatalf("Failed to decode response: %v", err) 545 + } 546 + 547 + for _, sg := range resp2.Suggestions { 548 + if sg.Status != "open" { 549 + t.Errorf("Expected all suggestions to have status 'open', got %q for ID %d", sg.Status, sg.ID) 550 + } 551 + } 552 + }) 553 + 554 + // ===================================================================== 555 + // Test: List Suggestions - Pagination 556 + // ===================================================================== 557 + t.Run("List suggestions - pagination", func(t *testing.T) { 558 + listRouter, listAuth := setupSuggestionTestRouter(t, []string{adminDID}) 559 + listUserToken := listAuth.AddUser(userDID) 560 + 561 + // Create 5 suggestions 562 + for i := 0; i < 5; i++ { 563 + mustCreateTestSuggestion(t, listRouter, listUserToken, 564 + fmt.Sprintf("Pagination Test %d", i), 565 + fmt.Sprintf("Description for pagination test %d", i)) 566 + } 567 + 568 + // First page: limit=2 569 + rec := listSuggestionsRequest(t, listRouter, listUserToken, "sort=new&limit=2") 570 + if rec.Code != http.StatusOK { 571 + t.Fatalf("Expected 200, got %d: %s", rec.Code, rec.Body.String()) 572 + } 573 + 574 + var page1 listSuggestionsResponse 575 + if err := json.NewDecoder(rec.Body).Decode(&page1); err != nil { 576 + t.Fatalf("Failed to decode page 1: %v", err) 577 + } 578 + 579 + if len(page1.Suggestions) != 2 { 580 + t.Fatalf("Expected 2 suggestions on page 1, got %d", len(page1.Suggestions)) 581 + } 582 + 583 + // Cursor should be non-empty since there are more results 584 + if page1.Cursor == "" { 585 + t.Fatal("Expected non-empty cursor on page 1") 586 + } 587 + 588 + // Second page: use cursor from first page 589 + rec2 := listSuggestionsRequest(t, listRouter, listUserToken, 590 + fmt.Sprintf("sort=new&limit=2&cursor=%s", page1.Cursor)) 591 + if rec2.Code != http.StatusOK { 592 + t.Fatalf("Expected 200, got %d: %s", rec2.Code, rec2.Body.String()) 593 + } 594 + 595 + var page2 listSuggestionsResponse 596 + if err := json.NewDecoder(rec2.Body).Decode(&page2); err != nil { 597 + t.Fatalf("Failed to decode page 2: %v", err) 598 + } 599 + 600 + if len(page2.Suggestions) != 2 { 601 + t.Fatalf("Expected 2 suggestions on page 2, got %d", len(page2.Suggestions)) 602 + } 603 + 604 + // Ensure page 2 suggestions are different from page 1 605 + page1IDs := map[int64]bool{ 606 + page1.Suggestions[0].ID: true, 607 + page1.Suggestions[1].ID: true, 608 + } 609 + for _, sg := range page2.Suggestions { 610 + if page1IDs[sg.ID] { 611 + t.Errorf("Suggestion ID %d appeared on both page 1 and page 2", sg.ID) 612 + } 613 + } 614 + 615 + // Third page: should have 1 result and empty cursor 616 + rec3 := listSuggestionsRequest(t, listRouter, listUserToken, 617 + fmt.Sprintf("sort=new&limit=2&cursor=%s", page2.Cursor)) 618 + if rec3.Code != http.StatusOK { 619 + t.Fatalf("Expected 200, got %d: %s", rec3.Code, rec3.Body.String()) 620 + } 621 + 622 + var page3 listSuggestionsResponse 623 + if err := json.NewDecoder(rec3.Body).Decode(&page3); err != nil { 624 + t.Fatalf("Failed to decode page 3: %v", err) 625 + } 626 + 627 + if len(page3.Suggestions) != 1 { 628 + t.Fatalf("Expected 1 suggestion on page 3, got %d", len(page3.Suggestions)) 629 + } 630 + 631 + // Cursor should be empty since there are no more results 632 + if page3.Cursor != "" { 633 + t.Errorf("Expected empty cursor on last page, got %q", page3.Cursor) 634 + } 635 + }) 636 + 637 + // ===================================================================== 638 + // Test: Vote on Suggestion 639 + // ===================================================================== 640 + t.Run("Vote on suggestion", func(t *testing.T) { 641 + voteRouter, voteAuth := setupSuggestionTestRouter(t, []string{adminDID}) 642 + voteUserToken := voteAuth.AddUser(userDID) 643 + voteUser2Token := voteAuth.AddUser(user2DID) 644 + 645 + // Create a suggestion 646 + created := mustCreateTestSuggestion(t, voteRouter, voteUserToken, 647 + "Vote Test Community", "Testing voting functionality") 648 + 649 + // Vote +1 650 + rec := voteOnSuggestionRequest(t, voteRouter, voteUser2Token, created.ID, 1) 651 + if rec.Code != http.StatusOK { 652 + t.Fatalf("Expected 200, got %d: %s", rec.Code, rec.Body.String()) 653 + } 654 + 655 + // Verify vote_count incremented by getting the suggestion 656 + getRec := getSuggestionRequest(t, voteRouter, voteUser2Token, created.ID) 657 + if getRec.Code != http.StatusOK { 658 + t.Fatalf("Expected 200, got %d: %s", getRec.Code, getRec.Body.String()) 659 + } 660 + 661 + var getResp suggestionResponse 662 + if err := json.NewDecoder(getRec.Body).Decode(&getResp); err != nil { 663 + t.Fatalf("Failed to decode get response: %v", err) 664 + } 665 + 666 + if getResp.VoteCount != 1 { 667 + t.Errorf("Expected voteCount 1, got %d", getResp.VoteCount) 668 + } 669 + 670 + // Verify viewer state shows vote = 1 for the voter 671 + if getResp.Viewer == nil { 672 + t.Fatal("Expected non-nil Viewer state for authenticated user") 673 + } 674 + if getResp.Viewer.Vote == nil { 675 + t.Fatal("Expected non-nil Viewer.Vote for user who voted") 676 + } 677 + if *getResp.Viewer.Vote != 1 { 678 + t.Errorf("Expected Viewer.Vote = 1, got %d", *getResp.Viewer.Vote) 679 + } 680 + }) 681 + 682 + // ===================================================================== 683 + // Test: Vote Toggle (Same Direction Removes) 684 + // ===================================================================== 685 + t.Run("Vote toggle - same direction removes", func(t *testing.T) { 686 + toggleRouter, toggleAuth := setupSuggestionTestRouter(t, []string{adminDID}) 687 + toggleUserToken := toggleAuth.AddUser(userDID) 688 + toggleUser2Token := toggleAuth.AddUser(user2DID) 689 + 690 + created := mustCreateTestSuggestion(t, toggleRouter, toggleUserToken, 691 + "Toggle Test", "Testing vote toggle") 692 + 693 + // Vote +1 694 + rec1 := voteOnSuggestionRequest(t, toggleRouter, toggleUser2Token, created.ID, 1) 695 + if rec1.Code != http.StatusOK { 696 + t.Fatalf("First vote failed: %d: %s", rec1.Code, rec1.Body.String()) 697 + } 698 + 699 + // Vote +1 again (should toggle off) 700 + rec2 := voteOnSuggestionRequest(t, toggleRouter, toggleUser2Token, created.ID, 1) 701 + if rec2.Code != http.StatusOK { 702 + t.Fatalf("Toggle vote failed: %d: %s", rec2.Code, rec2.Body.String()) 703 + } 704 + 705 + // Verify vote_count is back to 0 706 + getRec := getSuggestionRequest(t, toggleRouter, toggleUser2Token, created.ID) 707 + if getRec.Code != http.StatusOK { 708 + t.Fatalf("Expected 200, got %d: %s", getRec.Code, getRec.Body.String()) 709 + } 710 + 711 + var getResp suggestionResponse 712 + if err := json.NewDecoder(getRec.Body).Decode(&getResp); err != nil { 713 + t.Fatalf("Failed to decode get response: %v", err) 714 + } 715 + 716 + if getResp.VoteCount != 0 { 717 + t.Errorf("Expected voteCount 0 after toggle, got %d", getResp.VoteCount) 718 + } 719 + 720 + // Viewer state should NOT show a vote (vote was removed) 721 + if getResp.Viewer != nil && getResp.Viewer.Vote != nil { 722 + t.Errorf("Expected nil Viewer.Vote after toggle, got %d", *getResp.Viewer.Vote) 723 + } 724 + }) 725 + 726 + // ===================================================================== 727 + // Test: Vote Flip (Opposite Direction Changes) 728 + // ===================================================================== 729 + t.Run("Vote flip - opposite direction changes", func(t *testing.T) { 730 + flipRouter, flipAuth := setupSuggestionTestRouter(t, []string{adminDID}) 731 + flipUserToken := flipAuth.AddUser(userDID) 732 + flipUser2Token := flipAuth.AddUser(user2DID) 733 + 734 + created := mustCreateTestSuggestion(t, flipRouter, flipUserToken, 735 + "Flip Test", "Testing vote flip") 736 + 737 + // Vote +1 738 + rec1 := voteOnSuggestionRequest(t, flipRouter, flipUser2Token, created.ID, 1) 739 + if rec1.Code != http.StatusOK { 740 + t.Fatalf("First vote failed: %d: %s", rec1.Code, rec1.Body.String()) 741 + } 742 + 743 + // Vote -1 (should flip) 744 + rec2 := voteOnSuggestionRequest(t, flipRouter, flipUser2Token, created.ID, -1) 745 + if rec2.Code != http.StatusOK { 746 + t.Fatalf("Flip vote failed: %d: %s", rec2.Code, rec2.Body.String()) 747 + } 748 + 749 + // Verify vote_count is -1 750 + getRec := getSuggestionRequest(t, flipRouter, flipUser2Token, created.ID) 751 + if getRec.Code != http.StatusOK { 752 + t.Fatalf("Expected 200, got %d: %s", getRec.Code, getRec.Body.String()) 753 + } 754 + 755 + var getResp suggestionResponse 756 + if err := json.NewDecoder(getRec.Body).Decode(&getResp); err != nil { 757 + t.Fatalf("Failed to decode get response: %v", err) 758 + } 759 + 760 + if getResp.VoteCount != -1 { 761 + t.Errorf("Expected voteCount -1 after flip, got %d", getResp.VoteCount) 762 + } 763 + 764 + // Viewer state should show vote = -1 765 + if getResp.Viewer == nil || getResp.Viewer.Vote == nil { 766 + t.Fatal("Expected non-nil Viewer.Vote after flip") 767 + } 768 + if *getResp.Viewer.Vote != -1 { 769 + t.Errorf("Expected Viewer.Vote = -1, got %d", *getResp.Viewer.Vote) 770 + } 771 + }) 772 + 773 + // ===================================================================== 774 + // Test: Remove Vote Explicitly 775 + // ===================================================================== 776 + t.Run("Remove vote explicitly", func(t *testing.T) { 777 + removeRouter, removeAuth := setupSuggestionTestRouter(t, []string{adminDID}) 778 + removeUserToken := removeAuth.AddUser(userDID) 779 + removeUser2Token := removeAuth.AddUser(user2DID) 780 + 781 + created := mustCreateTestSuggestion(t, removeRouter, removeUserToken, 782 + "Remove Vote Test", "Testing explicit vote removal") 783 + 784 + // Vote +1 785 + rec1 := voteOnSuggestionRequest(t, removeRouter, removeUser2Token, created.ID, 1) 786 + if rec1.Code != http.StatusOK { 787 + t.Fatalf("Vote failed: %d: %s", rec1.Code, rec1.Body.String()) 788 + } 789 + 790 + // Verify vote_count is 1 791 + getRec := getSuggestionRequest(t, removeRouter, removeUser2Token, created.ID) 792 + var beforeResp suggestionResponse 793 + if err := json.NewDecoder(getRec.Body).Decode(&beforeResp); err != nil { 794 + t.Fatalf("Failed to decode: %v", err) 795 + } 796 + if beforeResp.VoteCount != 1 { 797 + t.Fatalf("Expected voteCount 1 before removal, got %d", beforeResp.VoteCount) 798 + } 799 + 800 + // Explicitly remove vote 801 + removeRec := removeVoteRequest(t, removeRouter, removeUser2Token, created.ID) 802 + if removeRec.Code != http.StatusOK { 803 + t.Fatalf("Remove vote failed: %d: %s", removeRec.Code, removeRec.Body.String()) 804 + } 805 + 806 + // Verify vote_count is back to 0 807 + getRec2 := getSuggestionRequest(t, removeRouter, removeUser2Token, created.ID) 808 + if getRec2.Code != http.StatusOK { 809 + t.Fatalf("Expected 200, got %d: %s", getRec2.Code, getRec2.Body.String()) 810 + } 811 + 812 + var afterResp suggestionResponse 813 + if err := json.NewDecoder(getRec2.Body).Decode(&afterResp); err != nil { 814 + t.Fatalf("Failed to decode response: %v", err) 815 + } 816 + 817 + if afterResp.VoteCount != 0 { 818 + t.Errorf("Expected voteCount 0 after removal, got %d", afterResp.VoteCount) 819 + } 820 + }) 821 + 822 + // ===================================================================== 823 + // Test: Vote - Suggestion Not Found 824 + // ===================================================================== 825 + t.Run("Vote - suggestion not found", func(t *testing.T) { 826 + rec := voteOnSuggestionRequest(t, router, userToken, 999999, 1) 827 + if rec.Code != http.StatusNotFound { 828 + t.Fatalf("Expected 404, got %d: %s", rec.Code, rec.Body.String()) 829 + } 830 + 831 + var errResp xrpcErrorResponse 832 + if err := json.NewDecoder(rec.Body).Decode(&errResp); err != nil { 833 + t.Fatalf("Failed to decode error response: %v", err) 834 + } 835 + if errResp.Error != "NotFound" { 836 + t.Errorf("Expected error 'NotFound', got %q", errResp.Error) 837 + } 838 + }) 839 + 840 + // ===================================================================== 841 + // Test: Update Status - Admin 842 + // ===================================================================== 843 + t.Run("Update status - admin", func(t *testing.T) { 844 + statusRouter, statusAuth := setupSuggestionTestRouter(t, []string{adminDID}) 845 + statusUserToken := statusAuth.AddUser(userDID) 846 + statusAdminToken := statusAuth.AddUser(adminDID) 847 + 848 + created := mustCreateTestSuggestion(t, statusRouter, statusUserToken, 849 + "Status Update Test", "Testing admin status update") 850 + 851 + // Admin updates status to approved 852 + rec := updateStatusRequest(t, statusRouter, statusAdminToken, created.ID, "approved") 853 + if rec.Code != http.StatusOK { 854 + t.Fatalf("Expected 200, got %d: %s", rec.Code, rec.Body.String()) 855 + } 856 + 857 + // Verify status changed 858 + getRec := getSuggestionRequest(t, statusRouter, statusUserToken, created.ID) 859 + if getRec.Code != http.StatusOK { 860 + t.Fatalf("Expected 200, got %d: %s", getRec.Code, getRec.Body.String()) 861 + } 862 + 863 + var getResp suggestionResponse 864 + if err := json.NewDecoder(getRec.Body).Decode(&getResp); err != nil { 865 + t.Fatalf("Failed to decode response: %v", err) 866 + } 867 + 868 + if getResp.Status != "approved" { 869 + t.Errorf("Expected status 'approved', got %q", getResp.Status) 870 + } 871 + }) 872 + 873 + // ===================================================================== 874 + // Test: Update Status - Non-Admin Forbidden 875 + // ===================================================================== 876 + t.Run("Update status - non-admin forbidden", func(t *testing.T) { 877 + statusRouter, statusAuth := setupSuggestionTestRouter(t, []string{adminDID}) 878 + statusUserToken := statusAuth.AddUser(userDID) 879 + 880 + created := mustCreateTestSuggestion(t, statusRouter, statusUserToken, 881 + "Forbidden Status Test", "Non-admin should not update") 882 + 883 + // Non-admin tries to update status 884 + rec := updateStatusRequest(t, statusRouter, statusUserToken, created.ID, "approved") 885 + if rec.Code != http.StatusForbidden { 886 + t.Fatalf("Expected 403, got %d: %s", rec.Code, rec.Body.String()) 887 + } 888 + 889 + var errResp xrpcErrorResponse 890 + if err := json.NewDecoder(rec.Body).Decode(&errResp); err != nil { 891 + t.Fatalf("Failed to decode error response: %v", err) 892 + } 893 + if errResp.Error != "Forbidden" { 894 + t.Errorf("Expected error 'Forbidden', got %q", errResp.Error) 895 + } 896 + }) 897 + 898 + // ===================================================================== 899 + // Test: Rate Limiting (3 suggestions per day per DID) 900 + // ===================================================================== 901 + t.Run("Rate limiting - max suggestions per day", func(t *testing.T) { 902 + rlRouter, rlAuth := setupSuggestionTestRouter(t, []string{adminDID}) 903 + rlUserToken := rlAuth.AddUser(user3DID) 904 + 905 + // Create MaxSuggestionsPerDay suggestions (should succeed) 906 + for i := 0; i < communitysuggestions.MaxSuggestionsPerDay; i++ { 907 + rec := createTestSuggestionRequest(t, rlRouter, rlUserToken, 908 + fmt.Sprintf("Rate Limit Test %d", i), 909 + fmt.Sprintf("Description %d", i)) 910 + if rec.Code != http.StatusOK { 911 + t.Fatalf("Expected 200 for suggestion %d, got %d: %s", i, rec.Code, rec.Body.String()) 912 + } 913 + } 914 + 915 + // Try to create one more (should fail with 429) 916 + rec := createTestSuggestionRequest(t, rlRouter, rlUserToken, 917 + "Over Limit", "Should be rate limited") 918 + if rec.Code != http.StatusTooManyRequests { 919 + t.Fatalf("Expected 429, got %d: %s", rec.Code, rec.Body.String()) 920 + } 921 + 922 + var errResp xrpcErrorResponse 923 + if err := json.NewDecoder(rec.Body).Decode(&errResp); err != nil { 924 + t.Fatalf("Failed to decode error response: %v", err) 925 + } 926 + if errResp.Error != "RateLimitExceeded" { 927 + t.Errorf("Expected error 'RateLimitExceeded', got %q", errResp.Error) 928 + } 929 + }) 930 + 931 + // ===================================================================== 932 + // Test: List with Viewer State - Authenticated 933 + // ===================================================================== 934 + t.Run("List suggestions - viewer state populated for authenticated user", func(t *testing.T) { 935 + vsRouter, vsAuth := setupSuggestionTestRouter(t, []string{adminDID}) 936 + vsUserToken := vsAuth.AddUser(userDID) 937 + vsUser2Token := vsAuth.AddUser(user2DID) 938 + 939 + // Create two suggestions 940 + s1 := mustCreateTestSuggestion(t, vsRouter, vsUserToken, 941 + "Viewer State Test 1", "User will vote on this") 942 + _ = mustCreateTestSuggestion(t, vsRouter, vsUserToken, 943 + "Viewer State Test 2", "User will not vote on this") 944 + 945 + // User2 votes on s1 946 + voteRec := voteOnSuggestionRequest(t, vsRouter, vsUser2Token, s1.ID, 1) 947 + if voteRec.Code != http.StatusOK { 948 + t.Fatalf("Vote failed: %d: %s", voteRec.Code, voteRec.Body.String()) 949 + } 950 + 951 + // List as user2 (should see voter state) 952 + rec := listSuggestionsRequest(t, vsRouter, vsUser2Token, "sort=new") 953 + if rec.Code != http.StatusOK { 954 + t.Fatalf("Expected 200, got %d: %s", rec.Code, rec.Body.String()) 955 + } 956 + 957 + var resp listSuggestionsResponse 958 + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { 959 + t.Fatalf("Failed to decode response: %v", err) 960 + } 961 + 962 + // Find s1 in the list and verify viewer state 963 + var foundS1 bool 964 + for _, sg := range resp.Suggestions { 965 + if sg.ID == s1.ID { 966 + foundS1 = true 967 + if sg.Viewer == nil || sg.Viewer.Vote == nil { 968 + t.Error("Expected non-nil Viewer.Vote for voted suggestion in list") 969 + } else if *sg.Viewer.Vote != 1 { 970 + t.Errorf("Expected Viewer.Vote = 1, got %d", *sg.Viewer.Vote) 971 + } 972 + } 973 + } 974 + if !foundS1 { 975 + t.Error("Expected to find s1 in list response") 976 + } 977 + }) 978 + 979 + // ===================================================================== 980 + // Test: List without Auth - No Viewer State 981 + // ===================================================================== 982 + t.Run("List suggestions - no viewer state for unauthenticated", func(t *testing.T) { 983 + noAuthRouter, noAuthAuth := setupSuggestionTestRouter(t, []string{adminDID}) 984 + noAuthUserToken := noAuthAuth.AddUser(userDID) 985 + 986 + mustCreateTestSuggestion(t, noAuthRouter, noAuthUserToken, 987 + "No Auth Viewer Test", "No viewer state expected") 988 + 989 + // List without auth token 990 + rec := listSuggestionsRequest(t, noAuthRouter, "", "sort=new") 991 + if rec.Code != http.StatusOK { 992 + t.Fatalf("Expected 200, got %d: %s", rec.Code, rec.Body.String()) 993 + } 994 + 995 + var resp listSuggestionsResponse 996 + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { 997 + t.Fatalf("Failed to decode response: %v", err) 998 + } 999 + 1000 + for _, sg := range resp.Suggestions { 1001 + if sg.Viewer != nil { 1002 + t.Errorf("Expected nil Viewer for unauthenticated request, got non-nil for ID %d", sg.ID) 1003 + } 1004 + } 1005 + }) 1006 + 1007 + // ===================================================================== 1008 + // Test: Update Status - All Valid Statuses 1009 + // ===================================================================== 1010 + t.Run("Update status - all valid transitions", func(t *testing.T) { 1011 + allStatusRouter, allStatusAuth := setupSuggestionTestRouter(t, []string{adminDID}) 1012 + allStatusUserToken := allStatusAuth.AddUser(userDID) 1013 + allStatusAdminToken := allStatusAuth.AddUser(adminDID) 1014 + 1015 + validStatuses := []string{"under_review", "approved", "declined", "open"} 1016 + 1017 + for _, status := range validStatuses { 1018 + created := mustCreateTestSuggestion(t, allStatusRouter, allStatusUserToken, 1019 + fmt.Sprintf("Status %s Test", status), 1020 + fmt.Sprintf("Testing transition to %s", status)) 1021 + 1022 + rec := updateStatusRequest(t, allStatusRouter, allStatusAdminToken, created.ID, status) 1023 + if rec.Code != http.StatusOK { 1024 + t.Errorf("Expected 200 for status %q, got %d: %s", status, rec.Code, rec.Body.String()) 1025 + } 1026 + 1027 + // Verify 1028 + getRec := getSuggestionRequest(t, allStatusRouter, allStatusUserToken, created.ID) 1029 + var getResp suggestionResponse 1030 + if err := json.NewDecoder(getRec.Body).Decode(&getResp); err != nil { 1031 + t.Fatalf("Failed to decode response: %v", err) 1032 + } 1033 + if getResp.Status != status { 1034 + t.Errorf("Expected status %q, got %q", status, getResp.Status) 1035 + } 1036 + } 1037 + }) 1038 + 1039 + // ===================================================================== 1040 + // Test: Multiple Users Voting 1041 + // ===================================================================== 1042 + t.Run("Multiple users voting on same suggestion", func(t *testing.T) { 1043 + multiRouter, multiAuth := setupSuggestionTestRouter(t, []string{adminDID}) 1044 + multiUserToken := multiAuth.AddUser(userDID) 1045 + multiUser2Token := multiAuth.AddUser(user2DID) 1046 + multiUser3Token := multiAuth.AddUser(user3DID) 1047 + 1048 + created := mustCreateTestSuggestion(t, multiRouter, multiUserToken, 1049 + "Multi Vote Test", "Multiple users vote here") 1050 + 1051 + // User 1 votes +1 1052 + rec1 := voteOnSuggestionRequest(t, multiRouter, multiUserToken, created.ID, 1) 1053 + if rec1.Code != http.StatusOK { 1054 + t.Fatalf("User1 vote failed: %d: %s", rec1.Code, rec1.Body.String()) 1055 + } 1056 + 1057 + // User 2 votes +1 1058 + rec2 := voteOnSuggestionRequest(t, multiRouter, multiUser2Token, created.ID, 1) 1059 + if rec2.Code != http.StatusOK { 1060 + t.Fatalf("User2 vote failed: %d: %s", rec2.Code, rec2.Body.String()) 1061 + } 1062 + 1063 + // User 3 votes -1 1064 + rec3 := voteOnSuggestionRequest(t, multiRouter, multiUser3Token, created.ID, -1) 1065 + if rec3.Code != http.StatusOK { 1066 + t.Fatalf("User3 vote failed: %d: %s", rec3.Code, rec3.Body.String()) 1067 + } 1068 + 1069 + // Verify total: +1 +1 -1 = +1 1070 + getRec := getSuggestionRequest(t, multiRouter, multiUserToken, created.ID) 1071 + var getResp suggestionResponse 1072 + if err := json.NewDecoder(getRec.Body).Decode(&getResp); err != nil { 1073 + t.Fatalf("Failed to decode response: %v", err) 1074 + } 1075 + 1076 + if getResp.VoteCount != 1 { 1077 + t.Errorf("Expected voteCount 1 (from +1 +1 -1), got %d", getResp.VoteCount) 1078 + } 1079 + 1080 + // Verify viewer state for user1 (voted +1) 1081 + if getResp.Viewer == nil || getResp.Viewer.Vote == nil { 1082 + t.Fatal("Expected non-nil Viewer.Vote for user1") 1083 + } 1084 + if *getResp.Viewer.Vote != 1 { 1085 + t.Errorf("Expected Viewer.Vote = 1 for user1, got %d", *getResp.Viewer.Vote) 1086 + } 1087 + }) 1088 + 1089 + // ===================================================================== 1090 + // Test: Vote Auth Required 1091 + // ===================================================================== 1092 + t.Run("Vote - auth required", func(t *testing.T) { 1093 + // Try to vote without auth 1094 + rec := voteOnSuggestionRequest(t, router, "", 1, 1) 1095 + if rec.Code != http.StatusUnauthorized { 1096 + t.Fatalf("Expected 401 for unauthenticated vote, got %d: %s", rec.Code, rec.Body.String()) 1097 + } 1098 + }) 1099 + 1100 + // ===================================================================== 1101 + // Test: Remove Vote Auth Required 1102 + // ===================================================================== 1103 + t.Run("Remove vote - auth required", func(t *testing.T) { 1104 + rec := removeVoteRequest(t, router, "", 1) 1105 + if rec.Code != http.StatusUnauthorized { 1106 + t.Fatalf("Expected 401 for unauthenticated remove vote, got %d: %s", rec.Code, rec.Body.String()) 1107 + } 1108 + }) 1109 + 1110 + // ===================================================================== 1111 + // Test: Update Status Auth Required 1112 + // ===================================================================== 1113 + t.Run("Update status - auth required", func(t *testing.T) { 1114 + rec := updateStatusRequest(t, router, "", 1, "approved") 1115 + if rec.Code != http.StatusUnauthorized { 1116 + t.Fatalf("Expected 401 for unauthenticated status update, got %d: %s", rec.Code, rec.Body.String()) 1117 + } 1118 + }) 1119 + } 1120 + 1121 + // TestCommunitySuggestionE2E_ViewerStateOnGet tests that the get endpoint properly 1122 + // populates viewer state for authenticated users. 1123 + func TestCommunitySuggestionE2E_ViewerStateOnGet(t *testing.T) { 1124 + if testing.Short() { 1125 + t.Skip("Skipping E2E test in short mode") 1126 + } 1127 + 1128 + adminDID := "did:plc:testadmin" 1129 + userDID := "did:plc:vieweruser1" 1130 + user2DID := "did:plc:vieweruser2" 1131 + 1132 + router, e2eAuth := setupSuggestionTestRouter(t, []string{adminDID}) 1133 + userToken := e2eAuth.AddUser(userDID) 1134 + user2Token := e2eAuth.AddUser(user2DID) 1135 + 1136 + // Create a suggestion 1137 + created := mustCreateTestSuggestion(t, router, userToken, 1138 + "Viewer Get Test", "Testing viewer state on get") 1139 + 1140 + // User2 votes +1 1141 + voteRec := voteOnSuggestionRequest(t, router, user2Token, created.ID, 1) 1142 + if voteRec.Code != http.StatusOK { 1143 + t.Fatalf("Vote failed: %d: %s", voteRec.Code, voteRec.Body.String()) 1144 + } 1145 + 1146 + t.Run("Voter sees their vote in viewer state", func(t *testing.T) { 1147 + getRec := getSuggestionRequest(t, router, user2Token, created.ID) 1148 + if getRec.Code != http.StatusOK { 1149 + t.Fatalf("Expected 200, got %d: %s", getRec.Code, getRec.Body.String()) 1150 + } 1151 + 1152 + var resp suggestionResponse 1153 + if err := json.NewDecoder(getRec.Body).Decode(&resp); err != nil { 1154 + t.Fatalf("Failed to decode: %v", err) 1155 + } 1156 + 1157 + if resp.Viewer == nil || resp.Viewer.Vote == nil { 1158 + t.Fatal("Expected non-nil Viewer.Vote for voter") 1159 + } 1160 + if *resp.Viewer.Vote != 1 { 1161 + t.Errorf("Expected Viewer.Vote = 1, got %d", *resp.Viewer.Vote) 1162 + } 1163 + }) 1164 + 1165 + t.Run("Non-voter sees no vote in viewer state", func(t *testing.T) { 1166 + // User1 did NOT vote, should see nil viewer.vote 1167 + getRec := getSuggestionRequest(t, router, userToken, created.ID) 1168 + if getRec.Code != http.StatusOK { 1169 + t.Fatalf("Expected 200, got %d: %s", getRec.Code, getRec.Body.String()) 1170 + } 1171 + 1172 + var resp suggestionResponse 1173 + if err := json.NewDecoder(getRec.Body).Decode(&resp); err != nil { 1174 + t.Fatalf("Failed to decode: %v", err) 1175 + } 1176 + 1177 + // Viewer state should be nil since user1 didn't vote 1178 + if resp.Viewer != nil && resp.Viewer.Vote != nil { 1179 + t.Errorf("Expected nil Viewer.Vote for non-voter, got %d", *resp.Viewer.Vote) 1180 + } 1181 + }) 1182 + 1183 + t.Run("Unauthenticated user sees no viewer state", func(t *testing.T) { 1184 + getRec := getSuggestionRequest(t, router, "", created.ID) 1185 + if getRec.Code != http.StatusOK { 1186 + t.Fatalf("Expected 200, got %d: %s", getRec.Code, getRec.Body.String()) 1187 + } 1188 + 1189 + var resp suggestionResponse 1190 + if err := json.NewDecoder(getRec.Body).Decode(&resp); err != nil { 1191 + t.Fatalf("Failed to decode: %v", err) 1192 + } 1193 + 1194 + if resp.Viewer != nil { 1195 + t.Error("Expected nil Viewer for unauthenticated request") 1196 + } 1197 + }) 1198 + } 1199 + 1200 + // TestCommunitySuggestionE2E_DownvoteFlow tests the full downvote lifecycle: 1201 + // downvote, toggle off, then upvote. 1202 + func TestCommunitySuggestionE2E_DownvoteFlow(t *testing.T) { 1203 + if testing.Short() { 1204 + t.Skip("Skipping E2E test in short mode") 1205 + } 1206 + 1207 + adminDID := "did:plc:testadmin" 1208 + userDID := "did:plc:downvoteuser1" 1209 + voterDID := "did:plc:downvotevoter1" 1210 + 1211 + router, e2eAuth := setupSuggestionTestRouter(t, []string{adminDID}) 1212 + userToken := e2eAuth.AddUser(userDID) 1213 + voterToken := e2eAuth.AddUser(voterDID) 1214 + 1215 + created := mustCreateTestSuggestion(t, router, userToken, 1216 + "Downvote Flow Test", "Testing downvote lifecycle") 1217 + 1218 + // Step 1: Downvote (-1) 1219 + rec1 := voteOnSuggestionRequest(t, router, voterToken, created.ID, -1) 1220 + if rec1.Code != http.StatusOK { 1221 + t.Fatalf("Downvote failed: %d: %s", rec1.Code, rec1.Body.String()) 1222 + } 1223 + 1224 + getRec1 := getSuggestionRequest(t, router, voterToken, created.ID) 1225 + var resp1 suggestionResponse 1226 + if err := json.NewDecoder(getRec1.Body).Decode(&resp1); err != nil { 1227 + t.Fatalf("Failed to decode: %v", err) 1228 + } 1229 + if resp1.VoteCount != -1 { 1230 + t.Errorf("Step 1: Expected voteCount -1, got %d", resp1.VoteCount) 1231 + } 1232 + if resp1.Viewer == nil || resp1.Viewer.Vote == nil || *resp1.Viewer.Vote != -1 { 1233 + t.Error("Step 1: Expected Viewer.Vote = -1") 1234 + } 1235 + 1236 + // Step 2: Downvote again (toggle off) 1237 + rec2 := voteOnSuggestionRequest(t, router, voterToken, created.ID, -1) 1238 + if rec2.Code != http.StatusOK { 1239 + t.Fatalf("Toggle downvote failed: %d: %s", rec2.Code, rec2.Body.String()) 1240 + } 1241 + 1242 + getRec2 := getSuggestionRequest(t, router, voterToken, created.ID) 1243 + var resp2 suggestionResponse 1244 + if err := json.NewDecoder(getRec2.Body).Decode(&resp2); err != nil { 1245 + t.Fatalf("Failed to decode: %v", err) 1246 + } 1247 + if resp2.VoteCount != 0 { 1248 + t.Errorf("Step 2: Expected voteCount 0, got %d", resp2.VoteCount) 1249 + } 1250 + 1251 + // Step 3: Upvote (+1) after removing downvote 1252 + rec3 := voteOnSuggestionRequest(t, router, voterToken, created.ID, 1) 1253 + if rec3.Code != http.StatusOK { 1254 + t.Fatalf("Upvote failed: %d: %s", rec3.Code, rec3.Body.String()) 1255 + } 1256 + 1257 + getRec3 := getSuggestionRequest(t, router, voterToken, created.ID) 1258 + var resp3 suggestionResponse 1259 + if err := json.NewDecoder(getRec3.Body).Decode(&resp3); err != nil { 1260 + t.Fatalf("Failed to decode: %v", err) 1261 + } 1262 + if resp3.VoteCount != 1 { 1263 + t.Errorf("Step 3: Expected voteCount 1, got %d", resp3.VoteCount) 1264 + } 1265 + if resp3.Viewer == nil || resp3.Viewer.Vote == nil || *resp3.Viewer.Vote != 1 { 1266 + t.Error("Step 3: Expected Viewer.Vote = 1") 1267 + } 1268 + }