From 4fed4b907e8ad3779ee5ba3efa6010712a99bc0a Mon Sep 17 00:00:00 2001 From: Bretton Date: Fri, 27 Feb 2026 17:15:27 -0800 Subject: [PATCH] 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 --- .claude/commands/fix-pr.md | 66 + CLAUDE.md | 6 +- cmd/server/main.go | 16 + internal/api/handlers/adminreport/errors.go | 21 +- .../api/handlers/adminreport/submit_test.go | 5 +- .../handlers/communitysuggestion/create.go | 81 ++ .../handlers/communitysuggestion/errors.go | 55 + .../api/handlers/communitysuggestion/get.go | 67 + .../api/handlers/communitysuggestion/list.go | 128 ++ .../communitysuggestion/update_status.go | 97 ++ .../api/handlers/communitysuggestion/vote.go | 119 ++ internal/api/routes/communitysuggestion.go | 76 + internal/api/xrpc/errors.go | 25 + internal/core/communitysuggestions/errors.go | 73 + .../core/communitysuggestions/interfaces.go | 76 + internal/core/communitysuggestions/service.go | 133 ++ .../core/communitysuggestions/suggestion.go | 214 +++ .../030_create_community_suggestions.sql | 38 + .../db/postgres/community_suggestion_repo.go | 550 +++++++ scripts/setup-mobile-ports.sh | 2 + .../community_suggestion_e2e_test.go | 1268 +++++++++++++++++ 21 files changed, 3094 insertions(+), 22 deletions(-) create mode 100644 .claude/commands/fix-pr.md create mode 100644 internal/api/handlers/communitysuggestion/create.go create mode 100644 internal/api/handlers/communitysuggestion/errors.go create mode 100644 internal/api/handlers/communitysuggestion/get.go create mode 100644 internal/api/handlers/communitysuggestion/list.go create mode 100644 internal/api/handlers/communitysuggestion/update_status.go create mode 100644 internal/api/handlers/communitysuggestion/vote.go create mode 100644 internal/api/routes/communitysuggestion.go create mode 100644 internal/api/xrpc/errors.go create mode 100644 internal/core/communitysuggestions/errors.go create mode 100644 internal/core/communitysuggestions/interfaces.go create mode 100644 internal/core/communitysuggestions/service.go create mode 100644 internal/core/communitysuggestions/suggestion.go create mode 100644 internal/db/migrations/030_create_community_suggestions.sql create mode 100644 internal/db/postgres/community_suggestion_repo.go create mode 100644 tests/integration/community_suggestion_e2e_test.go diff --git a/.claude/commands/fix-pr.md b/.claude/commands/fix-pr.md new file mode 100644 index 0000000..e47571b --- /dev/null +++ b/.claude/commands/fix-pr.md @@ -0,0 +1,66 @@ +# Fix PR Comments + +Analyze the current changed work and fix PR review comments using parallel subagents to avoid context rot. + +## Input + +The user will paste PR review comments after the command. The comments are provided below: + +$ARGUMENTS + +## Workflow + +### Step 1: Analyze Current State + +Run these commands to understand the full scope of changes: +1. `git diff --stat` — overview of changed files +2. `git diff` — full diff of all current changes (staged + unstaged) +3. `git status` — current working tree state + +Read through the diff carefully. Build a mental model of what was changed, which files are involved, and the architecture of the changes. + +### Step 2: Parse and Group the PR Comments + +From the pasted PR comments above, identify every distinct issue. Group them into **2-3 independent chunks** based on: +- Which files they touch (keep file-adjacent issues together) +- Logical coupling (issues that affect each other should be in the same chunk) +- Roughly equal workload per chunk + +**Grouping heuristic:** +- **≤3 issues total** → 2 chunks +- **4+ issues total** → 3 chunks +- Never put tightly coupled issues in different chunks (e.g., if fixing issue A changes code that issue B also references, they go together) + +### Step 3: Launch Subagents in Parallel + +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). + +Each subagent prompt MUST include: +1. **The full git diff** (or the relevant portions for their files) so they understand the current state +2. **The specific PR comments** they are responsible for fixing, quoted verbatim +3. **Clear instructions**: fix the issues described, follow CLAUDE.md guidelines, and run `go vet ./...` after making changes to verify correctness +4. **File scope**: explicitly list which files they should be reading/editing + +**Important subagent instructions to include:** +- "You are fixing PR review comments on existing changed code. Read the relevant files first, then make targeted fixes." +- "After making changes, run `go vet ./...` and `go build ./...` to verify no issues were introduced." +- "Follow all CLAUDE.md guidelines — parameterized queries, proper error handling, context.Context everywhere, no stubs." +- "Do NOT refactor beyond what the PR comment asks for. Make minimal, focused fixes." + +### Step 3: Review Results + +After all subagents complete: +1. Run `go vet ./...` and `go build ./...` to verify the full project compiles cleanly +2. Run `git diff --stat` to summarize what was changed +3. Report to the user: + - Which PR comments were addressed + - What changes were made + - Whether `go vet` and `go build` pass + - Any comments that couldn't be fully addressed and why + +## Notes + +- The goal is **isolated, focused fixes** — each subagent works on its own slice without polluting context for other fixes. +- If two subagents need to edit the same file, group those issues together in one chunk to avoid conflicts. +- Prefer fewer, larger chunks over many tiny ones — the overhead of each subagent matters. +- If a PR comment is unclear or seems wrong, flag it in the results rather than guessing. diff --git a/CLAUDE.md b/CLAUDE.md index 398a0cf..4b25a1b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -118,4 +118,8 @@ Your code is ready when: 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.** -Every line of code should be something you'd be proud to ship in a production system. Quality over speed. Completeness over convenience. \ No newline at end of file +Every line of code should be something you'd be proud to ship in a production system. Quality over speed. Completeness over convenience. + +## Subagent Execution + +When launching subagents via the Task tool, always run them in the **foreground** (`run_in_background: false`). Do not use background execution for subagents. \ No newline at end of file diff --git a/cmd/server/main.go b/cmd/server/main.go index b07d360..6521d38 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -41,6 +41,7 @@ import ( "Coves/internal/core/timeline" "Coves/internal/core/unfurl" "Coves/internal/core/adminreports" + "Coves/internal/core/communitysuggestions" "Coves/internal/core/userblocks" "Coves/internal/core/users" "Coves/internal/core/votes" @@ -630,6 +631,11 @@ func main() { adminReportService := adminreports.NewService(adminReportRepo) log.Println("✅ Admin report service initialized (for flagging serious content)") + // Initialize community suggestion service (off-protocol suggestion & voting) + communitySuggestionRepo := postgresRepo.NewCommunitySuggestionRepository(db) + communitySuggestionService := communitysuggestions.NewService(communitySuggestionRepo) + log.Println("✅ Community suggestion service initialized") + // Initialize feed service feedRepo := postgresRepo.NewCommunityFeedRepository(db, cursorSecret) feedService := communityFeeds.NewCommunityFeedService(feedRepo, communityService) @@ -828,6 +834,16 @@ func main() { log.Println("✅ Admin report endpoint registered (requires OAuth)") log.Println(" - POST /xrpc/social.coves.admin.submitReport") + // Register community suggestion routes (off-protocol suggestion & voting) + routes.RegisterCommunitySuggestionRoutes(r, communitySuggestionService, authMiddleware, allowedCommunityCreators) + log.Println("Community suggestion endpoints registered (off-protocol)") + log.Println(" - POST /xrpc/social.coves.community.suggestion.create (requires OAuth, rate limited)") + log.Println(" - GET /xrpc/social.coves.community.suggestion.list (optional auth)") + log.Println(" - GET /xrpc/social.coves.community.suggestion.get (optional auth)") + log.Println(" - POST /xrpc/social.coves.community.suggestion.vote (requires OAuth)") + log.Println(" - POST /xrpc/social.coves.community.suggestion.removeVote (requires OAuth)") + log.Println(" - POST /xrpc/social.coves.community.suggestion.updateStatus (admin only)") + routes.RegisterCommunityFeedRoutes(r, feedService, voteService, blueskyService, authMiddleware) log.Println("Feed XRPC endpoints registered (public with optional auth for viewer vote state)") diff --git a/internal/api/handlers/adminreport/errors.go b/internal/api/handlers/adminreport/errors.go index 79f285d..ef6971d 100644 --- a/internal/api/handlers/adminreport/errors.go +++ b/internal/api/handlers/adminreport/errors.go @@ -1,36 +1,22 @@ package adminreport import ( + "Coves/internal/api/xrpc" "Coves/internal/core/adminreports" - "encoding/json" "errors" "log" "net/http" ) -// errorResponse represents a standardized JSON error response -type errorResponse struct { - Error string `json:"error"` - Message string `json:"message"` -} - // writeError writes a JSON error response with the given status code func writeError(w http.ResponseWriter, statusCode int, errorType, message string) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(statusCode) - if err := json.NewEncoder(w).Encode(errorResponse{ - Error: errorType, - Message: message, - }); err != nil { - log.Printf("Failed to encode error response: %v", err) - } + xrpc.WriteError(w, statusCode, errorType, message) } // handleServiceError maps service-layer errors to HTTP responses func handleServiceError(w http.ResponseWriter, err error) { switch { case adminreports.IsValidationError(err): - // Map specific validation errors to appropriate messages switch { case errors.Is(err, adminreports.ErrInvalidReason): writeError(w, http.StatusBadRequest, "InvalidReason", @@ -51,8 +37,6 @@ func handleServiceError(w http.ResponseWriter, err error) { writeError(w, http.StatusBadRequest, "InvalidTargetType", "Invalid target type. Must be one of: post, comment") default: - // SECURITY: Don't expose internal error messages to clients - // Log the actual error for debugging, but return a generic message log.Printf("Unhandled validation error in admin report handler: %v", err) writeError(w, http.StatusBadRequest, "InvalidRequest", "The request contains invalid data") @@ -62,7 +46,6 @@ func handleServiceError(w http.ResponseWriter, err error) { writeError(w, http.StatusNotFound, "NotFound", "Report not found") default: - // SECURITY: Don't leak internal error details to clients log.Printf("Unexpected error in admin report handler: %v", err) writeError(w, http.StatusInternalServerError, "InternalServerError", "An internal error occurred") diff --git a/internal/api/handlers/adminreport/submit_test.go b/internal/api/handlers/adminreport/submit_test.go index f305e7a..920924b 100644 --- a/internal/api/handlers/adminreport/submit_test.go +++ b/internal/api/handlers/adminreport/submit_test.go @@ -2,6 +2,7 @@ package adminreport import ( "Coves/internal/api/middleware" + "Coves/internal/api/xrpc" "Coves/internal/core/adminreports" "bytes" "context" @@ -416,7 +417,7 @@ func TestWriteError(t *testing.T) { t.Errorf("expected status %d, got %d", http.StatusBadRequest, w.Code) } - var resp errorResponse + var resp xrpc.Error if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("failed to unmarshal error response: %v", err) } @@ -495,7 +496,7 @@ func TestHandleServiceError_AllValidationErrors(t *testing.T) { t.Errorf("expected status %d, got %d", tt.expectedStatus, w.Code) } - var resp errorResponse + var resp xrpc.Error if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("failed to unmarshal error response: %v", err) } diff --git a/internal/api/handlers/communitysuggestion/create.go b/internal/api/handlers/communitysuggestion/create.go new file mode 100644 index 0000000..efead64 --- /dev/null +++ b/internal/api/handlers/communitysuggestion/create.go @@ -0,0 +1,81 @@ +package communitysuggestion + +import ( + "Coves/internal/api/middleware" + "Coves/internal/core/communitysuggestions" + "encoding/json" + "log" + "net/http" +) + +// CreateHandler handles community suggestion creation +type CreateHandler struct { + service communitysuggestions.Service +} + +// NewCreateHandler creates a new create handler +func NewCreateHandler(service communitysuggestions.Service) *CreateHandler { + return &CreateHandler{ + service: service, + } +} + +// createSuggestionInput represents the JSON request body for creating a suggestion +type createSuggestionInput struct { + Title string `json:"title"` + Description string `json:"description"` +} + +// HandleCreate creates a new community suggestion +// POST /xrpc/social.coves.community.suggestion.create +func (h *CreateHandler) HandleCreate(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "MethodNotAllowed", "Method not allowed") + return + } + + // Limit request body size to 10KB to prevent DoS attacks + r.Body = http.MaxBytesReader(w, r.Body, 10*1024) + + // Parse JSON body + var input createSuggestionInput + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + log.Printf("[COMMUNITY_SUGGESTION] Failed to decode JSON request: %v", err) + writeError(w, http.StatusBadRequest, "InvalidRequest", "Invalid request body") + return + } + + // Extract authenticated user DID from request context (injected by auth middleware) + userDID := middleware.GetUserDID(r) + if userDID == "" { + writeError(w, http.StatusUnauthorized, "AuthRequired", "Authentication required") + return + } + + // Build the create request + req := communitysuggestions.CreateSuggestionRequest{ + Title: input.Title, + Description: input.Description, + SubmitterDID: userDID, + } + + // Create suggestion via service + suggestion, err := h.service.CreateSuggestion(r.Context(), req) + if err != nil { + handleServiceError(w, err) + return + } + + // Return full suggestion JSON on success + data, err := json.Marshal(suggestion) + if err != nil { + log.Printf("Failed to marshal create suggestion response: %v", err) + writeError(w, http.StatusInternalServerError, "InternalServerError", "An internal error occurred") + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + if _, err := w.Write(data); err != nil { + log.Printf("Failed to write response: %v", err) + } +} diff --git a/internal/api/handlers/communitysuggestion/errors.go b/internal/api/handlers/communitysuggestion/errors.go new file mode 100644 index 0000000..830e79c --- /dev/null +++ b/internal/api/handlers/communitysuggestion/errors.go @@ -0,0 +1,55 @@ +package communitysuggestion + +import ( + "Coves/internal/api/xrpc" + "Coves/internal/core/communitysuggestions" + "errors" + "log" + "net/http" +) + +// writeError writes an XRPC error response +func writeError(w http.ResponseWriter, status int, error, message string) { + xrpc.WriteError(w, status, error, message) +} + +// handleServiceError converts service errors to appropriate HTTP responses +// Each sentinel error is mapped to a static, user-facing message to prevent +// leaking internal error details to clients. +func handleServiceError(w http.ResponseWriter, err error) { + switch { + case communitysuggestions.IsValidationError(err): + switch { + case errors.Is(err, communitysuggestions.ErrTitleRequired): + writeError(w, http.StatusBadRequest, "InvalidRequest", "Suggestion title is required") + case errors.Is(err, communitysuggestions.ErrTitleTooLong): + writeError(w, http.StatusBadRequest, "InvalidRequest", "Suggestion title exceeds maximum length") + case errors.Is(err, communitysuggestions.ErrDescriptionRequired): + writeError(w, http.StatusBadRequest, "InvalidRequest", "Suggestion description is required") + case errors.Is(err, communitysuggestions.ErrDescriptionTooLong): + writeError(w, http.StatusBadRequest, "InvalidRequest", "Suggestion description exceeds maximum length") + case errors.Is(err, communitysuggestions.ErrInvalidStatus): + writeError(w, http.StatusBadRequest, "InvalidStatus", "Invalid status value. Must be one of: open, under_review, approved, declined") + case errors.Is(err, communitysuggestions.ErrInvalidVoteValue): + writeError(w, http.StatusBadRequest, "InvalidVoteValue", "Invalid vote value. Must be 1 or -1") + case errors.Is(err, communitysuggestions.ErrInvalidSuggestionID): + writeError(w, http.StatusBadRequest, "InvalidRequest", "Invalid suggestion ID") + case errors.Is(err, communitysuggestions.ErrVoterRequired): + writeError(w, http.StatusBadRequest, "InvalidRequest", "Voter identification is required") + case errors.Is(err, communitysuggestions.ErrSubmitterRequired): + writeError(w, http.StatusBadRequest, "InvalidRequest", "Submitter identification is required") + default: + log.Printf("Unhandled validation error in community suggestion handler: %v", err) + writeError(w, http.StatusBadRequest, "InvalidRequest", "The request contains invalid data") + } + case communitysuggestions.IsNotFound(err): + writeError(w, http.StatusNotFound, "NotFound", "The requested resource was not found") + case communitysuggestions.IsRateLimitError(err): + writeError(w, http.StatusTooManyRequests, "RateLimitExceeded", "Too many suggestions. Please try again later") + case communitysuggestions.IsAuthorizationError(err): + writeError(w, http.StatusForbidden, "Forbidden", "You are not authorized to perform this action") + default: + log.Printf("XRPC community suggestion handler error: %v", err) + writeError(w, http.StatusInternalServerError, "InternalServerError", "An internal error occurred") + } +} diff --git a/internal/api/handlers/communitysuggestion/get.go b/internal/api/handlers/communitysuggestion/get.go new file mode 100644 index 0000000..6ad66bc --- /dev/null +++ b/internal/api/handlers/communitysuggestion/get.go @@ -0,0 +1,67 @@ +package communitysuggestion + +import ( + "Coves/internal/api/middleware" + "Coves/internal/core/communitysuggestions" + "encoding/json" + "log" + "net/http" + "strconv" +) + +// GetHandler handles retrieving a single community suggestion +type GetHandler struct { + service communitysuggestions.Service +} + +// NewGetHandler creates a new get handler +func NewGetHandler(service communitysuggestions.Service) *GetHandler { + return &GetHandler{ + service: service, + } +} + +// HandleGet retrieves a single community suggestion by ID +// GET /xrpc/social.coves.community.suggestion.get?id=123 +func (h *GetHandler) HandleGet(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "MethodNotAllowed", "Method not allowed") + return + } + + // Parse id query param + idStr := r.URL.Query().Get("id") + if idStr == "" { + writeError(w, http.StatusBadRequest, "InvalidRequest", "Missing required parameter: id") + return + } + + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil || id <= 0 { + writeError(w, http.StatusBadRequest, "InvalidRequest", "Invalid id parameter: must be a positive integer") + return + } + + // Extract optional DID for viewer state + viewerDID := middleware.GetUserDID(r) + + // Get suggestion via service + suggestion, err := h.service.GetSuggestion(r.Context(), id, viewerDID) + if err != nil { + handleServiceError(w, err) + return + } + + // Return full suggestion JSON + data, err := json.Marshal(suggestion) + if err != nil { + log.Printf("Failed to marshal get suggestion response: %v", err) + writeError(w, http.StatusInternalServerError, "InternalServerError", "An internal error occurred") + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + if _, err := w.Write(data); err != nil { + log.Printf("Failed to write response: %v", err) + } +} diff --git a/internal/api/handlers/communitysuggestion/list.go b/internal/api/handlers/communitysuggestion/list.go new file mode 100644 index 0000000..3f34f7e --- /dev/null +++ b/internal/api/handlers/communitysuggestion/list.go @@ -0,0 +1,128 @@ +package communitysuggestion + +import ( + "Coves/internal/api/middleware" + "Coves/internal/core/communitysuggestions" + "encoding/json" + "log" + "net/http" + "strconv" +) + +// ListHandler handles listing community suggestions +type ListHandler struct { + service communitysuggestions.Service +} + +// NewListHandler creates a new list handler +func NewListHandler(service communitysuggestions.Service) *ListHandler { + return &ListHandler{ + service: service, + } +} + +// HandleList lists community suggestions with filters +// GET /xrpc/social.coves.community.suggestion.list?sort=popular&status=open&limit=50&cursor=0 +func (h *ListHandler) HandleList(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "MethodNotAllowed", "Method not allowed") + return + } + + // Parse query parameters + query := r.URL.Query() + + // Parse sort (default "popular", valid: "popular"|"new") + sort := query.Get("sort") + if sort == "" { + sort = "popular" + } + validSorts := map[string]bool{ + "popular": true, + "new": true, + } + if !validSorts[sort] { + writeError(w, http.StatusBadRequest, "InvalidRequest", "Invalid sort value. Must be: popular or new") + return + } + + // Parse status (optional, validate if present) + status := query.Get("status") + if status != "" && !communitysuggestions.IsValidStatus(status) { + writeError(w, http.StatusBadRequest, "InvalidRequest", "Invalid status value. Must be one of: open, under_review, approved, declined") + return + } + + // Parse limit (1-100, default 50) + limit := 50 + if limitStr := query.Get("limit"); limitStr != "" { + l, err := strconv.Atoi(limitStr) + if err != nil { + writeError(w, http.StatusBadRequest, "InvalidRequest", "Invalid limit parameter: must be an integer") + return + } + if l < 1 || l > 100 { + writeError(w, http.StatusBadRequest, "InvalidRequest", "Invalid limit parameter: must be between 1 and 100") + return + } + limit = l + } + + // Parse cursor (offset-based) + offset := 0 + if cursorStr := query.Get("cursor"); cursorStr != "" { + o, err := strconv.Atoi(cursorStr) + if err != nil { + writeError(w, http.StatusBadRequest, "InvalidRequest", "Invalid cursor parameter: must be an integer") + return + } + if o < 0 { + writeError(w, http.StatusBadRequest, "InvalidRequest", "Invalid cursor parameter: must be non-negative") + return + } + offset = o + } + + // Extract optional DID for viewer state + viewerDID := middleware.GetUserDID(r) + + // Build the list request + req := communitysuggestions.ListSuggestionsRequest{ + Sort: sort, + Status: status, + Limit: limit, + Offset: offset, + ViewerDID: viewerDID, + } + + // List suggestions via service + suggestions, err := h.service.ListSuggestions(r.Context(), req) + if err != nil { + handleServiceError(w, err) + return + } + + // Build cursor: next offset when there are more results + var cursor string + if len(suggestions) == limit { + cursor = strconv.Itoa(offset + len(suggestions)) + } + + // Build response + response := map[string]interface{}{ + "suggestions": suggestions, + "cursor": cursor, + } + + data, err := json.Marshal(response) + if err != nil { + log.Printf("Failed to marshal suggestion list response: %v", err) + writeError(w, http.StatusInternalServerError, "InternalServerError", "An internal error occurred") + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + if _, err := w.Write(data); err != nil { + log.Printf("Failed to write response: %v", err) + } +} diff --git a/internal/api/handlers/communitysuggestion/update_status.go b/internal/api/handlers/communitysuggestion/update_status.go new file mode 100644 index 0000000..ea79054 --- /dev/null +++ b/internal/api/handlers/communitysuggestion/update_status.go @@ -0,0 +1,97 @@ +package communitysuggestion + +import ( + "Coves/internal/api/middleware" + "Coves/internal/core/communitysuggestions" + "encoding/json" + "log" + "net/http" +) + +// UpdateStatusHandler handles updating a community suggestion's status (admin only) +type UpdateStatusHandler struct { + service communitysuggestions.Service + adminDIDs map[string]bool +} + +// NewUpdateStatusHandler creates a new update status handler +// adminDIDs is a list of DIDs that can update suggestion status +func NewUpdateStatusHandler(service communitysuggestions.Service, adminDIDs []string) *UpdateStatusHandler { + var adminMap map[string]bool + if len(adminDIDs) > 0 { + adminMap = make(map[string]bool) + for _, did := range adminDIDs { + if did != "" { // Skip empty strings + adminMap[did] = true + } + } + // If all entries were empty, no admins are configured — block all access + if len(adminMap) == 0 { + adminMap = nil + log.Printf("[WARN] All admin DID entries were empty — suggestion status updates will be blocked for all users") + } + } + return &UpdateStatusHandler{ + service: service, + adminDIDs: adminMap, + } +} + +// updateStatusInput represents the JSON request body for updating a suggestion's status +type updateStatusInput struct { + SuggestionID int64 `json:"suggestionId"` + Status string `json:"status"` +} + +// HandleUpdateStatus updates a community suggestion's status +// POST /xrpc/social.coves.community.suggestion.updateStatus +func (h *UpdateStatusHandler) HandleUpdateStatus(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "MethodNotAllowed", "Method not allowed") + return + } + + // Extract authenticated user DID + userDID := middleware.GetUserDID(r) + if userDID == "" { + writeError(w, http.StatusUnauthorized, "AuthRequired", "Authentication required") + return + } + + // Check if user is an admin + if h.adminDIDs == nil || !h.adminDIDs[userDID] { + writeError(w, http.StatusForbidden, "Forbidden", "Admin access required") + return + } + + // Limit request body size to 10KB + r.Body = http.MaxBytesReader(w, r.Body, 10*1024) + + // Parse JSON body + var input updateStatusInput + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + log.Printf("[COMMUNITY_SUGGESTION] Failed to decode update status JSON request: %v", err) + writeError(w, http.StatusBadRequest, "InvalidRequest", "Invalid request body") + return + } + + // Build the update status request + req := communitysuggestions.UpdateStatusRequest{ + SuggestionID: input.SuggestionID, + Status: communitysuggestions.Status(input.Status), + AdminDID: userDID, + } + + // Update status via service + if err := h.service.UpdateStatus(r.Context(), req); err != nil { + handleServiceError(w, err) + return + } + + // Return success + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + if _, err := w.Write([]byte(`{"success":true}`)); err != nil { + log.Printf("Failed to write response: %v", err) + } +} diff --git a/internal/api/handlers/communitysuggestion/vote.go b/internal/api/handlers/communitysuggestion/vote.go new file mode 100644 index 0000000..f0ddeeb --- /dev/null +++ b/internal/api/handlers/communitysuggestion/vote.go @@ -0,0 +1,119 @@ +package communitysuggestion + +import ( + "Coves/internal/api/middleware" + "Coves/internal/core/communitysuggestions" + "encoding/json" + "log" + "net/http" +) + +// VoteHandler handles voting on community suggestions +type VoteHandler struct { + service communitysuggestions.Service +} + +// NewVoteHandler creates a new vote handler +func NewVoteHandler(service communitysuggestions.Service) *VoteHandler { + return &VoteHandler{ + service: service, + } +} + +// voteInput represents the JSON request body for casting a vote +type voteInput struct { + SuggestionID int64 `json:"suggestionId"` + Value int `json:"value"` +} + +// removeVoteInput represents the JSON request body for removing a vote +type removeVoteInput struct { + SuggestionID int64 `json:"suggestionId"` +} + +// HandleVote casts or toggles a vote on a community suggestion +// POST /xrpc/social.coves.community.suggestion.vote +func (h *VoteHandler) HandleVote(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "MethodNotAllowed", "Method not allowed") + return + } + + // Limit request body size to 10KB + r.Body = http.MaxBytesReader(w, r.Body, 10*1024) + + // Parse JSON body + var input voteInput + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + log.Printf("[COMMUNITY_SUGGESTION] Failed to decode vote JSON request: %v", err) + writeError(w, http.StatusBadRequest, "InvalidRequest", "Invalid request body") + return + } + + // Extract authenticated user DID + userDID := middleware.GetUserDID(r) + if userDID == "" { + writeError(w, http.StatusUnauthorized, "AuthRequired", "Authentication required") + return + } + + // Build the vote request + req := communitysuggestions.VoteRequest{ + SuggestionID: input.SuggestionID, + VoterDID: userDID, + Value: input.Value, + } + + // Cast vote via service + if err := h.service.Vote(r.Context(), req); err != nil { + handleServiceError(w, err) + return + } + + // Return success + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + if _, err := w.Write([]byte(`{"success":true}`)); err != nil { + log.Printf("Failed to write response: %v", err) + } +} + +// HandleRemoveVote removes a vote from a community suggestion +// POST /xrpc/social.coves.community.suggestion.removeVote +func (h *VoteHandler) HandleRemoveVote(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "MethodNotAllowed", "Method not allowed") + return + } + + // Limit request body size to 10KB + r.Body = http.MaxBytesReader(w, r.Body, 10*1024) + + // Parse JSON body + var input removeVoteInput + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + log.Printf("[COMMUNITY_SUGGESTION] Failed to decode remove vote JSON request: %v", err) + writeError(w, http.StatusBadRequest, "InvalidRequest", "Invalid request body") + return + } + + // Extract authenticated user DID + userDID := middleware.GetUserDID(r) + if userDID == "" { + writeError(w, http.StatusUnauthorized, "AuthRequired", "Authentication required") + return + } + + // Remove vote via service + if err := h.service.RemoveVote(r.Context(), input.SuggestionID, userDID); err != nil { + handleServiceError(w, err) + return + } + + // Return success + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + if _, err := w.Write([]byte(`{"success":true}`)); err != nil { + log.Printf("Failed to write response: %v", err) + } +} diff --git a/internal/api/routes/communitysuggestion.go b/internal/api/routes/communitysuggestion.go new file mode 100644 index 0000000..4d04e64 --- /dev/null +++ b/internal/api/routes/communitysuggestion.go @@ -0,0 +1,76 @@ +package routes + +import ( + "Coves/internal/api/handlers/communitysuggestion" + "Coves/internal/api/middleware" + "Coves/internal/core/communitysuggestions" + "time" + + "github.com/go-chi/chi/v5" +) + +// RegisterCommunitySuggestionRoutes registers community suggestion XRPC endpoints on the router +// Implements social.coves.community.suggestion.* endpoints for community suggestions and voting +// adminDIDs restricts who can update suggestion status (reuses COMMUNITY_CREATORS env var) +func RegisterCommunitySuggestionRoutes( + r chi.Router, + service communitysuggestions.Service, + authMiddleware *middleware.OAuthAuthMiddleware, + adminDIDs []string, +) { + // Initialize handlers + createHandler := communitysuggestion.NewCreateHandler(service) + listHandler := communitysuggestion.NewListHandler(service) + getHandler := communitysuggestion.NewGetHandler(service) + voteHandler := communitysuggestion.NewVoteHandler(service) + updateStatusHandler := communitysuggestion.NewUpdateStatusHandler(service, adminDIDs) + + // Create IP rate limiter for suggestion creation + // Allow 10 requests per minute per IP to prevent abuse + createRateLimiter := middleware.NewRateLimiter(10, time.Minute) + + // Create IP rate limiter for voting + // Allow 30 requests per minute per IP to prevent vote-toggle abuse + voteRateLimiter := middleware.NewRateLimiter(30, time.Minute) + + // Query endpoints (GET) - public access, optional auth for viewer state + // social.coves.community.suggestion.list - list suggestions with filters + r.With(authMiddleware.OptionalAuth).Get( + "/xrpc/social.coves.community.suggestion.list", + listHandler.HandleList) + + // social.coves.community.suggestion.get - get a single suggestion by ID + r.With(authMiddleware.OptionalAuth).Get( + "/xrpc/social.coves.community.suggestion.get", + getHandler.HandleGet) + + // Procedure endpoints (POST) - require authentication + // social.coves.community.suggestion.create - create a new suggestion + r.With( + createRateLimiter.Middleware, + authMiddleware.RequireAuth, + ).Post( + "/xrpc/social.coves.community.suggestion.create", + createHandler.HandleCreate) + + // social.coves.community.suggestion.vote - cast or toggle a vote + r.With( + voteRateLimiter.Middleware, + authMiddleware.RequireAuth, + ).Post( + "/xrpc/social.coves.community.suggestion.vote", + voteHandler.HandleVote) + + // social.coves.community.suggestion.removeVote - remove a vote + r.With( + voteRateLimiter.Middleware, + authMiddleware.RequireAuth, + ).Post( + "/xrpc/social.coves.community.suggestion.removeVote", + voteHandler.HandleRemoveVote) + + // social.coves.community.suggestion.updateStatus - update suggestion status (admin only) + r.With(authMiddleware.RequireAuth).Post( + "/xrpc/social.coves.community.suggestion.updateStatus", + updateStatusHandler.HandleUpdateStatus) +} diff --git a/internal/api/xrpc/errors.go b/internal/api/xrpc/errors.go new file mode 100644 index 0000000..9cb1371 --- /dev/null +++ b/internal/api/xrpc/errors.go @@ -0,0 +1,25 @@ +package xrpc + +import ( + "encoding/json" + "log" + "net/http" +) + +// Error represents an XRPC error response +type Error struct { + Error string `json:"error"` + Message string `json:"message"` +} + +// WriteError writes an XRPC error response with the given status code +func WriteError(w http.ResponseWriter, statusCode int, errorType, message string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + if err := json.NewEncoder(w).Encode(Error{ + Error: errorType, + Message: message, + }); err != nil { + log.Printf("Failed to encode XRPC error response: %v", err) + } +} diff --git a/internal/core/communitysuggestions/errors.go b/internal/core/communitysuggestions/errors.go new file mode 100644 index 0000000..185d412 --- /dev/null +++ b/internal/core/communitysuggestions/errors.go @@ -0,0 +1,73 @@ +package communitysuggestions + +import "errors" + +var ( + // ErrSuggestionNotFound indicates the requested suggestion does not exist + ErrSuggestionNotFound = errors.New("suggestion not found") + + // ErrTitleRequired indicates the suggestion title was not provided + ErrTitleRequired = errors.New("suggestion title is required") + + // ErrTitleTooLong indicates the suggestion title exceeds the maximum length + ErrTitleTooLong = errors.New("suggestion title exceeds maximum length of 200 characters") + + // ErrDescriptionRequired indicates the suggestion description was not provided + ErrDescriptionRequired = errors.New("suggestion description is required") + + // ErrDescriptionTooLong indicates the suggestion description exceeds the maximum length + ErrDescriptionTooLong = errors.New("suggestion description exceeds maximum length of 5000 characters") + + // ErrInvalidStatus indicates the suggestion status is not a valid value + ErrInvalidStatus = errors.New("invalid suggestion status: must be one of open, under_review, approved, declined") + + // ErrInvalidVoteValue indicates the vote value is not valid (must be 1 or -1) + ErrInvalidVoteValue = errors.New("invalid vote value: must be 1 or -1") + + // ErrInvalidSuggestionID indicates the suggestion ID is invalid + ErrInvalidSuggestionID = errors.New("invalid suggestion ID: must be a positive integer") + + // ErrVoterRequired indicates the voter DID was not provided + ErrVoterRequired = errors.New("voter DID is required") + + // ErrSubmitterRequired indicates the submitter DID was not provided + ErrSubmitterRequired = errors.New("submitter DID is required") + + // ErrNotAuthorized indicates the user is not authorized to perform the action + ErrNotAuthorized = errors.New("not authorized to perform this action") + + // ErrRateLimitExceeded indicates the user has exceeded the suggestion creation rate limit + ErrRateLimitExceeded = errors.New("rate limit exceeded: maximum 3 suggestions per day") + + // ErrVoteNotFound indicates the requested vote does not exist + ErrVoteNotFound = errors.New("vote not found") +) + +// IsValidationError checks if an error is a validation error +func IsValidationError(err error) bool { + return errors.Is(err, ErrTitleRequired) || + errors.Is(err, ErrTitleTooLong) || + errors.Is(err, ErrDescriptionRequired) || + errors.Is(err, ErrDescriptionTooLong) || + errors.Is(err, ErrInvalidStatus) || + errors.Is(err, ErrInvalidVoteValue) || + errors.Is(err, ErrInvalidSuggestionID) || + errors.Is(err, ErrVoterRequired) || + errors.Is(err, ErrSubmitterRequired) +} + +// IsNotFound checks if an error is a "not found" error +func IsNotFound(err error) bool { + return errors.Is(err, ErrSuggestionNotFound) || + errors.Is(err, ErrVoteNotFound) +} + +// IsRateLimitError checks if an error is a rate limit error +func IsRateLimitError(err error) bool { + return errors.Is(err, ErrRateLimitExceeded) +} + +// IsAuthorizationError checks if an error is an authorization error +func IsAuthorizationError(err error) bool { + return errors.Is(err, ErrNotAuthorized) +} diff --git a/internal/core/communitysuggestions/interfaces.go b/internal/core/communitysuggestions/interfaces.go new file mode 100644 index 0000000..c0b3a3a --- /dev/null +++ b/internal/core/communitysuggestions/interfaces.go @@ -0,0 +1,76 @@ +package communitysuggestions + +import ( + "context" + "time" +) + +// Repository defines the data access layer for community suggestions +type Repository interface { + // Create stores a new suggestion in the database + // Returns the suggestion with ID, CreatedAt, and UpdatedAt populated + Create(ctx context.Context, suggestion *CommunitySuggestion) error + + // GetByID retrieves a single suggestion by its ID + // Returns ErrSuggestionNotFound if the suggestion does not exist + GetByID(ctx context.Context, id int64) (*CommunitySuggestion, error) + + // List retrieves suggestions with optional filtering and sorting + List(ctx context.Context, req ListSuggestionsRequest) ([]*CommunitySuggestion, error) + + // CountBySubmitterSince counts the number of suggestions created by a submitter since a given time + // Used for rate limiting suggestion creation + CountBySubmitterSince(ctx context.Context, submitterDID string, since time.Time) (int, error) + + // UpdateStatus updates a suggestion's status + // Returns ErrSuggestionNotFound if the suggestion does not exist + UpdateStatus(ctx context.Context, id int64, status Status) error + + // UpsertVote inserts or updates a vote for a suggestion + // Returns the delta to apply to the suggestion's vote count + UpsertVote(ctx context.Context, suggestionID int64, voterDID string, value int) (int, error) + + // DeleteVote removes a vote from a suggestion + // Returns the delta to apply to the suggestion's vote count + DeleteVote(ctx context.Context, suggestionID int64, voterDID string) (int, error) + + // GetVote retrieves a single vote by suggestion ID and voter DID + // Returns ErrVoteNotFound if the vote does not exist + GetVote(ctx context.Context, suggestionID int64, voterDID string) (*SuggestionVote, error) + + // GetVotesForViewer retrieves the votes cast by a viewer on a set of suggestions + // Returns a map of suggestion ID to vote value + GetVotesForViewer(ctx context.Context, voterDID string, suggestionIDs []int64) (map[int64]int, error) + + // AtomicVote atomically handles voting with toggle semantics in a single transaction + // If no existing vote: creates the vote + // If existing vote in same direction: removes the vote (toggle off) + // If existing vote in opposite direction: flips the vote + AtomicVote(ctx context.Context, suggestionID int64, voterDID string, value int) error +} + +// Service defines the business logic layer for community suggestions +type Service interface { + // CreateSuggestion validates and creates a new community suggestion + // Enforces rate limiting (max 3 suggestions per day per user) + CreateSuggestion(ctx context.Context, req CreateSuggestionRequest) (*CommunitySuggestion, error) + + // GetSuggestion retrieves a single suggestion by ID + // If viewerDID is non-empty, populates the viewer state with the viewer's vote + GetSuggestion(ctx context.Context, id int64, viewerDID string) (*CommunitySuggestion, error) + + // ListSuggestions retrieves suggestions with filtering, sorting, and pagination + // Populates viewer state for authenticated viewers + ListSuggestions(ctx context.Context, req ListSuggestionsRequest) ([]*CommunitySuggestion, error) + + // Vote casts or toggles a vote on a suggestion + // If the user already voted in the same direction, the vote is removed (toggle off) + // If the user already voted in the opposite direction, the vote is flipped + Vote(ctx context.Context, req VoteRequest) error + + // RemoveVote removes a user's vote from a suggestion + RemoveVote(ctx context.Context, suggestionID int64, voterDID string) error + + // UpdateStatus updates a suggestion's status (admin only) + UpdateStatus(ctx context.Context, req UpdateStatusRequest) error +} diff --git a/internal/core/communitysuggestions/service.go b/internal/core/communitysuggestions/service.go new file mode 100644 index 0000000..8f62e8f --- /dev/null +++ b/internal/core/communitysuggestions/service.go @@ -0,0 +1,133 @@ +package communitysuggestions + +import ( + "context" + "time" +) + +// service implements the Service interface for community suggestions +type service struct { + repo Repository +} + +// NewService creates a new community suggestions service +func NewService(repo Repository) Service { + return &service{ + repo: repo, + } +} + +// CreateSuggestion validates the request, checks the rate limit, and creates a new suggestion +func (s *service) CreateSuggestion(ctx context.Context, req CreateSuggestionRequest) (*CommunitySuggestion, error) { + // Validate the request + if err := req.Validate(); err != nil { + return nil, err + } + + // Check rate limit: max 3 suggestions per day per user + since := time.Now().UTC().Add(-24 * time.Hour) + count, err := s.repo.CountBySubmitterSince(ctx, req.SubmitterDID, since) + if err != nil { + return nil, err + } + if count >= MaxSuggestionsPerDay { + return nil, ErrRateLimitExceeded + } + + // Create the suggestion + suggestion := &CommunitySuggestion{ + Title: req.Title, + Description: req.Description, + SubmitterDID: req.SubmitterDID, + Status: StatusOpen, + } + + if err := s.repo.Create(ctx, suggestion); err != nil { + return nil, err + } + + return suggestion, nil +} + +// GetSuggestion retrieves a suggestion by ID and populates viewer state if viewerDID is provided +func (s *service) GetSuggestion(ctx context.Context, id int64, viewerDID string) (*CommunitySuggestion, error) { + suggestion, err := s.repo.GetByID(ctx, id) + if err != nil { + return nil, err + } + + // Populate viewer state if authenticated + if viewerDID != "" { + vote, err := s.repo.GetVote(ctx, id, viewerDID) + if err != nil && !IsNotFound(err) { + return nil, err + } + if vote != nil { + suggestion.Viewer = &ViewerState{Vote: &vote.Value} + } + } + + return suggestion, nil +} + +// ListSuggestions retrieves suggestions with filtering, sorting, and pagination +// Populates viewer state for all returned suggestions if viewerDID is provided +func (s *service) ListSuggestions(ctx context.Context, req ListSuggestionsRequest) ([]*CommunitySuggestion, error) { + suggestions, err := s.repo.List(ctx, req) + if err != nil { + return nil, err + } + + // Batch populate viewer state if authenticated + if req.ViewerDID != "" && len(suggestions) > 0 { + ids := make([]int64, len(suggestions)) + for i, sg := range suggestions { + ids[i] = sg.ID + } + + votes, err := s.repo.GetVotesForViewer(ctx, req.ViewerDID, ids) + if err != nil { + return nil, err + } + + for _, sg := range suggestions { + if v, ok := votes[sg.ID]; ok { + sg.Viewer = &ViewerState{Vote: &v} + } + } + } + + return suggestions, nil +} + +// Vote handles casting, toggling, and flipping votes on a suggestion +// - If no existing vote: create a new vote +// - If existing vote in the same direction: remove the vote (toggle off) +// - If existing vote in the opposite direction: flip the vote +func (s *service) Vote(ctx context.Context, req VoteRequest) error { + if err := req.Validate(); err != nil { + return err + } + return s.repo.AtomicVote(ctx, req.SuggestionID, req.VoterDID, req.Value) +} + +// RemoveVote removes a user's vote from a suggestion +func (s *service) RemoveVote(ctx context.Context, suggestionID int64, voterDID string) error { + if suggestionID <= 0 { + return ErrInvalidSuggestionID + } + if voterDID == "" { + return ErrVoterRequired + } + + _, err := s.repo.DeleteVote(ctx, suggestionID, voterDID) + return err +} + +// UpdateStatus validates the request and updates the suggestion's status +func (s *service) UpdateStatus(ctx context.Context, req UpdateStatusRequest) error { + if err := req.Validate(); err != nil { + return err + } + return s.repo.UpdateStatus(ctx, req.SuggestionID, req.Status) +} diff --git a/internal/core/communitysuggestions/suggestion.go b/internal/core/communitysuggestions/suggestion.go new file mode 100644 index 0000000..7e4512d --- /dev/null +++ b/internal/core/communitysuggestions/suggestion.go @@ -0,0 +1,214 @@ +package communitysuggestions + +import ( + "fmt" + "strings" + "time" + "unicode/utf8" +) + +// Status represents the processing status of a community suggestion +type Status string + +// Valid status values for community suggestions +const ( + StatusOpen Status = "open" + StatusUnderReview Status = "under_review" + StatusApproved Status = "approved" + StatusDeclined Status = "declined" +) + +// MaxTitleLength is the maximum number of characters allowed in a suggestion title +const MaxTitleLength = 200 + +// MaxDescriptionLength is the maximum number of characters allowed in a suggestion description +const MaxDescriptionLength = 5000 + +// MaxSuggestionsPerDay is the maximum number of suggestions a single user can create per day +const MaxSuggestionsPerDay = 3 + +// ValidStatuses returns all valid status values +func ValidStatuses() []Status { + return []Status{StatusOpen, StatusUnderReview, StatusApproved, StatusDeclined} +} + +// IsValidStatus checks if a status value is valid +func IsValidStatus(status string) bool { + for _, s := range ValidStatuses() { + if string(s) == status { + return true + } + } + return false +} + +// IsValidVoteValue checks if a vote value is valid (must be 1 or -1) +func IsValidVoteValue(v int) bool { + return v == 1 || v == -1 +} + +// CommunitySuggestion represents a community suggestion in the AppView database +type CommunitySuggestion struct { + ID int64 `json:"id" db:"id"` + Title string `json:"title" db:"title"` + Description string `json:"description" db:"description"` + SubmitterDID string `json:"submitterDid" db:"submitter_did"` + Status Status `json:"status" db:"status"` + VoteCount int `json:"voteCount" db:"vote_count"` + CreatedAt time.Time `json:"createdAt" db:"created_at"` + UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` + Viewer *ViewerState `json:"viewer,omitempty"` +} + +// ViewerState contains information about the authenticated viewer's relationship +// to a community suggestion (e.g., their vote) +type ViewerState struct { + Vote *int `json:"vote"` +} + +// SuggestionVote represents a single vote on a community suggestion +type SuggestionVote struct { + ID int64 `json:"id" db:"id"` + SuggestionID int64 `json:"suggestionId" db:"suggestion_id"` + VoterDID string `json:"voterDid" db:"voter_did"` + Value int `json:"value" db:"value"` + CreatedAt time.Time `json:"createdAt" db:"created_at"` +} + +// CreateSuggestionRequest contains the data needed to create a new community suggestion +type CreateSuggestionRequest struct { + // Title is the title of the community suggestion + Title string + + // Description is a detailed description of the suggested community + Description string + + // SubmitterDID is the DID of the user submitting the suggestion + SubmitterDID string +} + +// Validate validates the CreateSuggestionRequest and returns an error if invalid +func (r *CreateSuggestionRequest) Validate() error { + if r.SubmitterDID == "" { + return ErrSubmitterRequired + } + + r.Title = strings.TrimSpace(r.Title) + if r.Title == "" { + return ErrTitleRequired + } + if utf8.RuneCountInString(r.Title) > MaxTitleLength { + return ErrTitleTooLong + } + + r.Description = strings.TrimSpace(r.Description) + if r.Description == "" { + return ErrDescriptionRequired + } + if utf8.RuneCountInString(r.Description) > MaxDescriptionLength { + return ErrDescriptionTooLong + } + + return nil +} + +// ListSuggestionsRequest contains the parameters for listing community suggestions +type ListSuggestionsRequest struct { + // Sort determines the ordering: "popular" (vote_count DESC) or "new" (created_at DESC) + Sort string + + // Status optionally filters by suggestion status + Status string + + // Limit is the maximum number of results to return + Limit int + + // Offset is the number of results to skip for pagination + Offset int + + // ViewerDID is the DID of the authenticated viewer (for populating viewer state) + ViewerDID string +} + +// Validate validates the ListSuggestionsRequest, applying defaults for missing values +func (r *ListSuggestionsRequest) Validate() error { + // Default/validate sort + if r.Sort == "" { + r.Sort = "popular" + } + if r.Sort != "popular" && r.Sort != "new" { + return fmt.Errorf("invalid sort value: must be popular or new") + } + + // Validate status if provided + if r.Status != "" && !IsValidStatus(r.Status) { + return ErrInvalidStatus + } + + // Default/validate limit + if r.Limit <= 0 { + r.Limit = 50 + } + if r.Limit > 100 { + r.Limit = 100 + } + + // Validate offset + if r.Offset < 0 { + r.Offset = 0 + } + + return nil +} + +// VoteRequest contains the data needed to cast a vote on a community suggestion +type VoteRequest struct { + // SuggestionID is the ID of the suggestion to vote on + SuggestionID int64 + + // VoterDID is the DID of the user casting the vote + VoterDID string + + // Value is the vote value: 1 (upvote) or -1 (downvote) + Value int +} + +// Validate validates the VoteRequest and returns an error if invalid +func (r *VoteRequest) Validate() error { + if r.SuggestionID <= 0 { + return ErrInvalidSuggestionID + } + if r.VoterDID == "" { + return ErrVoterRequired + } + if !IsValidVoteValue(r.Value) { + return ErrInvalidVoteValue + } + return nil +} + +// UpdateStatusRequest contains the data needed to update a suggestion's status +type UpdateStatusRequest struct { + // SuggestionID is the ID of the suggestion to update + SuggestionID int64 + + // Status is the new status value + Status Status + + // AdminDID is the DID of the admin performing the update + AdminDID string +} + +// Validate validates the UpdateStatusRequest and returns an error if invalid +func (r *UpdateStatusRequest) Validate() error { + if r.SuggestionID <= 0 { + return ErrInvalidSuggestionID + } + if r.AdminDID == "" { + return ErrNotAuthorized + } + if !IsValidStatus(string(r.Status)) { + return ErrInvalidStatus + } + return nil +} diff --git a/internal/db/migrations/030_create_community_suggestions.sql b/internal/db/migrations/030_create_community_suggestions.sql new file mode 100644 index 0000000..e8def7f --- /dev/null +++ b/internal/db/migrations/030_create_community_suggestions.sql @@ -0,0 +1,38 @@ +-- +goose Up +CREATE TABLE community_suggestions ( + id BIGSERIAL PRIMARY KEY, + title TEXT NOT NULL, + description TEXT NOT NULL, + submitter_did TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'open', + vote_count INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT valid_suggestion_status CHECK (status IN ('open', 'under_review', 'approved', 'declined')), + CONSTRAINT title_not_empty CHECK (LENGTH(TRIM(title)) > 0), + CONSTRAINT title_max_length CHECK (LENGTH(title) <= 200), + CONSTRAINT description_max_length CHECK (LENGTH(description) <= 5000), + CONSTRAINT description_not_empty CHECK (LENGTH(TRIM(description)) > 0) +); + +CREATE TABLE suggestion_votes ( + id BIGSERIAL PRIMARY KEY, + suggestion_id BIGINT NOT NULL REFERENCES community_suggestions(id) ON DELETE CASCADE, + voter_did TEXT NOT NULL, + value SMALLINT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT valid_vote_value CHECK (value IN (1, -1)), + CONSTRAINT unique_suggestion_voter UNIQUE (suggestion_id, voter_did) +); + +-- Indexes +CREATE INDEX idx_suggestions_status ON community_suggestions(status); +CREATE INDEX idx_suggestions_created_at ON community_suggestions(created_at DESC); +CREATE INDEX idx_suggestions_vote_count ON community_suggestions(vote_count DESC); +CREATE INDEX idx_suggestions_submitter ON community_suggestions(submitter_did); +CREATE INDEX idx_suggestion_votes_suggestion ON suggestion_votes(suggestion_id); +CREATE INDEX idx_suggestion_votes_voter ON suggestion_votes(voter_did); + +-- +goose Down +DROP TABLE IF EXISTS suggestion_votes; +DROP TABLE IF EXISTS community_suggestions; diff --git a/internal/db/postgres/community_suggestion_repo.go b/internal/db/postgres/community_suggestion_repo.go new file mode 100644 index 0000000..47a702b --- /dev/null +++ b/internal/db/postgres/community_suggestion_repo.go @@ -0,0 +1,550 @@ +package postgres + +import ( + "Coves/internal/core/communitysuggestions" + "context" + "database/sql" + "fmt" + "log/slog" + "strings" + "time" + + "github.com/lib/pq" +) + +type postgresCommunitySuggestionRepo struct { + db *sql.DB +} + +// NewCommunitySuggestionRepository creates a new PostgreSQL community suggestion repository +func NewCommunitySuggestionRepository(db *sql.DB) communitysuggestions.Repository { + return &postgresCommunitySuggestionRepo{db: db} +} + +// Create inserts a new community suggestion into the database +// Returns the suggestion with ID, CreatedAt, and UpdatedAt populated +func (r *postgresCommunitySuggestionRepo) Create(ctx context.Context, suggestion *communitysuggestions.CommunitySuggestion) error { + query := ` + INSERT INTO community_suggestions ( + title, description, submitter_did, status + ) VALUES ( + $1, $2, $3, $4 + ) + RETURNING id, created_at, updated_at + ` + + status := suggestion.Status + if status == "" { + status = communitysuggestions.StatusOpen + } + + err := r.db.QueryRowContext( + ctx, query, + suggestion.Title, suggestion.Description, + suggestion.SubmitterDID, string(status), + ).Scan(&suggestion.ID, &suggestion.CreatedAt, &suggestion.UpdatedAt) + + if err != nil { + if pqErr := extractPQError(err); pqErr != nil { + if strings.Contains(pqErr.Constraint, "valid_suggestion_status") { + return communitysuggestions.ErrInvalidStatus + } + if strings.Contains(pqErr.Constraint, "title_not_empty") { + return communitysuggestions.ErrTitleRequired + } + if strings.Contains(pqErr.Constraint, "title_max_length") { + return communitysuggestions.ErrTitleTooLong + } + if strings.Contains(pqErr.Constraint, "description_not_empty") { + return communitysuggestions.ErrDescriptionRequired + } + if strings.Contains(pqErr.Constraint, "description_max_length") { + return communitysuggestions.ErrDescriptionTooLong + } + } + return fmt.Errorf("failed to create community suggestion: %w", err) + } + + suggestion.Status = status + return nil +} + +// GetByID retrieves a single community suggestion by its ID +// Returns ErrSuggestionNotFound if the suggestion does not exist +func (r *postgresCommunitySuggestionRepo) GetByID(ctx context.Context, id int64) (*communitysuggestions.CommunitySuggestion, error) { + query := ` + SELECT id, title, description, submitter_did, status, + vote_count, created_at, updated_at + FROM community_suggestions + WHERE id = $1 + ` + + var suggestion communitysuggestions.CommunitySuggestion + var status string + + err := r.db.QueryRowContext(ctx, query, id).Scan( + &suggestion.ID, &suggestion.Title, &suggestion.Description, + &suggestion.SubmitterDID, &status, + &suggestion.VoteCount, &suggestion.CreatedAt, &suggestion.UpdatedAt, + ) + if err != nil { + if err == sql.ErrNoRows { + return nil, communitysuggestions.ErrSuggestionNotFound + } + return nil, fmt.Errorf("failed to get community suggestion by ID: %w", err) + } + + suggestion.Status = communitysuggestions.Status(status) + return &suggestion, nil +} + +// List retrieves community suggestions with optional filtering and sorting +// Supports sorting by "popular" (vote_count DESC, created_at DESC) or "new" (created_at DESC) +// Supports optional filtering by status +func (r *postgresCommunitySuggestionRepo) List(ctx context.Context, req communitysuggestions.ListSuggestionsRequest) ([]*communitysuggestions.CommunitySuggestion, error) { + var queryBuilder strings.Builder + var args []interface{} + argIndex := 1 + + queryBuilder.WriteString(` + SELECT id, title, description, submitter_did, status, + vote_count, created_at, updated_at + FROM community_suggestions + `) + + // Optional status filter + if req.Status != "" { + queryBuilder.WriteString(fmt.Sprintf(" WHERE status = $%d", argIndex)) + args = append(args, req.Status) + argIndex++ + } + + // Sorting + switch req.Sort { + case "popular": + queryBuilder.WriteString(" ORDER BY vote_count DESC, created_at DESC") + case "new", "": + queryBuilder.WriteString(" ORDER BY created_at DESC") + default: + return nil, fmt.Errorf("unknown sort value: %s", req.Sort) + } + + // Pagination + queryBuilder.WriteString(fmt.Sprintf(" LIMIT $%d OFFSET $%d", argIndex, argIndex+1)) + limit := req.Limit + if limit <= 0 { + limit = 50 + } + args = append(args, limit, req.Offset) + + rows, err := r.db.QueryContext(ctx, queryBuilder.String(), args...) + if err != nil { + return nil, fmt.Errorf("failed to list community suggestions: %w", err) + } + defer func() { + if closeErr := rows.Close(); closeErr != nil { + slog.Warn("failed to close rows in List community suggestions", + slog.String("error", closeErr.Error()), + ) + } + }() + + var suggestions []*communitysuggestions.CommunitySuggestion + for rows.Next() { + suggestion, err := scanSuggestion(rows) + if err != nil { + return nil, err + } + suggestions = append(suggestions, suggestion) + } + + if err = rows.Err(); err != nil { + return nil, fmt.Errorf("error iterating community suggestions: %w", err) + } + + return suggestions, nil +} + +// CountBySubmitterSince counts the number of suggestions created by a submitter since a given time +// Used for rate limiting suggestion creation +func (r *postgresCommunitySuggestionRepo) CountBySubmitterSince(ctx context.Context, submitterDID string, since time.Time) (int, error) { + query := ` + SELECT COUNT(*) + FROM community_suggestions + WHERE submitter_did = $1 AND created_at >= $2 + ` + + var count int + err := r.db.QueryRowContext(ctx, query, submitterDID, since).Scan(&count) + if err != nil { + return 0, fmt.Errorf("failed to count suggestions by submitter: %w", err) + } + + return count, nil +} + +// UpdateStatus updates a suggestion's status +// Returns ErrSuggestionNotFound if the suggestion does not exist +func (r *postgresCommunitySuggestionRepo) UpdateStatus(ctx context.Context, id int64, status communitysuggestions.Status) error { + query := ` + UPDATE community_suggestions + SET status = $1, updated_at = NOW() + WHERE id = $2 + ` + + result, err := r.db.ExecContext(ctx, query, string(status), id) + if err != nil { + if pqErr := extractPQError(err); pqErr != nil { + if strings.Contains(pqErr.Constraint, "valid_suggestion_status") { + return communitysuggestions.ErrInvalidStatus + } + } + return fmt.Errorf("failed to update community suggestion status: %w", err) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("failed to check update result: %w", err) + } + + if rowsAffected == 0 { + return communitysuggestions.ErrSuggestionNotFound + } + + return nil +} + +// UpsertVote inserts or updates a vote for a suggestion and atomically updates the vote count +// Returns the delta applied to the suggestion's vote count +// Uses a transaction to ensure consistency between the vote and the denormalized count +func (r *postgresCommunitySuggestionRepo) UpsertVote(ctx context.Context, suggestionID int64, voterDID string, value int) (int, error) { + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return 0, fmt.Errorf("failed to begin transaction for upsert vote: %w", err) + } + defer func() { + if rbErr := tx.Rollback(); rbErr != nil && rbErr != sql.ErrTxDone { + slog.Warn("failed to rollback upsert vote transaction", + slog.String("error", rbErr.Error()), + ) + } + }() + + // Check for existing vote with row lock + var existingValue int + var hasExisting bool + selectQuery := ` + SELECT value FROM suggestion_votes + WHERE suggestion_id = $1 AND voter_did = $2 + FOR UPDATE + ` + err = tx.QueryRowContext(ctx, selectQuery, suggestionID, voterDID).Scan(&existingValue) + if err != nil && err != sql.ErrNoRows { + return 0, fmt.Errorf("failed to check existing vote: %w", err) + } + hasExisting = err == nil + + var delta int + if hasExisting { + // Update existing vote + updateQuery := ` + UPDATE suggestion_votes + SET value = $1 + WHERE suggestion_id = $2 AND voter_did = $3 + ` + _, err = tx.ExecContext(ctx, updateQuery, value, suggestionID, voterDID) + if err != nil { + return 0, fmt.Errorf("failed to update vote: %w", err) + } + // Delta is the difference between new and old value + delta = value - existingValue + } else { + // Insert new vote + insertQuery := ` + INSERT INTO suggestion_votes (suggestion_id, voter_did, value) + VALUES ($1, $2, $3) + ` + _, err = tx.ExecContext(ctx, insertQuery, suggestionID, voterDID, value) + if err != nil { + if pqErr := extractPQError(err); pqErr != nil { + if strings.Contains(pqErr.Constraint, "valid_vote_value") { + return 0, communitysuggestions.ErrInvalidVoteValue + } + } + return 0, fmt.Errorf("failed to insert vote: %w", err) + } + delta = value + } + + // Atomically update the denormalized vote count + updateCountQuery := ` + UPDATE community_suggestions + SET vote_count = vote_count + $1, updated_at = NOW() + WHERE id = $2 + ` + _, err = tx.ExecContext(ctx, updateCountQuery, delta, suggestionID) + if err != nil { + return 0, fmt.Errorf("failed to update vote count: %w", err) + } + + if err = tx.Commit(); err != nil { + return 0, fmt.Errorf("failed to commit upsert vote transaction: %w", err) + } + + return delta, nil +} + +// DeleteVote removes a vote from a suggestion and atomically updates the vote count +// Returns the delta applied to the suggestion's vote count +// Uses a transaction to ensure consistency between the vote deletion and the denormalized count +func (r *postgresCommunitySuggestionRepo) DeleteVote(ctx context.Context, suggestionID int64, voterDID string) (int, error) { + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return 0, fmt.Errorf("failed to begin transaction for delete vote: %w", err) + } + defer func() { + if rbErr := tx.Rollback(); rbErr != nil && rbErr != sql.ErrTxDone { + slog.Warn("failed to rollback delete vote transaction", + slog.String("error", rbErr.Error()), + ) + } + }() + + // Delete the vote and get the deleted value + deleteQuery := ` + DELETE FROM suggestion_votes + WHERE suggestion_id = $1 AND voter_did = $2 + RETURNING value + ` + var deletedValue int + err = tx.QueryRowContext(ctx, deleteQuery, suggestionID, voterDID).Scan(&deletedValue) + if err != nil { + if err == sql.ErrNoRows { + return 0, communitysuggestions.ErrVoteNotFound + } + return 0, fmt.Errorf("failed to delete vote: %w", err) + } + + // Atomically update the denormalized vote count (subtract the deleted vote value) + delta := -deletedValue + updateCountQuery := ` + UPDATE community_suggestions + SET vote_count = vote_count + $1, updated_at = NOW() + WHERE id = $2 + ` + result, err := tx.ExecContext(ctx, updateCountQuery, delta, suggestionID) + if err != nil { + return 0, fmt.Errorf("failed to update vote count after delete: %w", err) + } + rowsAffected, err := result.RowsAffected() + if err != nil { + return 0, fmt.Errorf("failed to check vote count update result: %w", err) + } + if rowsAffected == 0 { + return 0, communitysuggestions.ErrSuggestionNotFound + } + + if err = tx.Commit(); err != nil { + return 0, fmt.Errorf("failed to commit delete vote transaction: %w", err) + } + + return delta, nil +} + +// AtomicVote atomically handles voting with toggle semantics in a single transaction +// If no existing vote: creates the vote +// If existing vote in same direction: removes the vote (toggle off) +// If existing vote in opposite direction: flips the vote +func (r *postgresCommunitySuggestionRepo) AtomicVote(ctx context.Context, suggestionID int64, voterDID string, value int) error { + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("failed to begin transaction for atomic vote: %w", err) + } + defer func() { + if rbErr := tx.Rollback(); rbErr != nil && rbErr != sql.ErrTxDone { + slog.Warn("failed to rollback atomic vote transaction", + slog.String("error", rbErr.Error()), + ) + } + }() + + // Verify suggestion exists and lock the row to prevent concurrent modification + var exists bool + existsQuery := `SELECT EXISTS(SELECT 1 FROM community_suggestions WHERE id = $1 FOR UPDATE)` + err = tx.QueryRowContext(ctx, existsQuery, suggestionID).Scan(&exists) + if err != nil { + return fmt.Errorf("failed to check suggestion existence: %w", err) + } + if !exists { + return communitysuggestions.ErrSuggestionNotFound + } + + // Check for existing vote with row lock + var existingValue int + var hasExisting bool + selectQuery := ` + SELECT value FROM suggestion_votes + WHERE suggestion_id = $1 AND voter_did = $2 + FOR UPDATE + ` + err = tx.QueryRowContext(ctx, selectQuery, suggestionID, voterDID).Scan(&existingValue) + if err != nil && err != sql.ErrNoRows { + return fmt.Errorf("failed to check existing vote: %w", err) + } + hasExisting = err == nil + + var delta int + if hasExisting { + if existingValue == value { + // Same direction: toggle off (remove the vote) + deleteQuery := ` + DELETE FROM suggestion_votes + WHERE suggestion_id = $1 AND voter_did = $2 + ` + _, err = tx.ExecContext(ctx, deleteQuery, suggestionID, voterDID) + if err != nil { + return fmt.Errorf("failed to delete vote during toggle: %w", err) + } + delta = -existingValue + } else { + // Opposite direction: flip the vote + updateQuery := ` + UPDATE suggestion_votes + SET value = $1 + WHERE suggestion_id = $2 AND voter_did = $3 + ` + _, err = tx.ExecContext(ctx, updateQuery, value, suggestionID, voterDID) + if err != nil { + return fmt.Errorf("failed to update vote during flip: %w", err) + } + delta = value - existingValue + } + } else { + // No existing vote: insert new + insertQuery := ` + INSERT INTO suggestion_votes (suggestion_id, voter_did, value) + VALUES ($1, $2, $3) + ` + _, err = tx.ExecContext(ctx, insertQuery, suggestionID, voterDID, value) + if err != nil { + if pqErr := extractPQError(err); pqErr != nil { + if pqErr.Code == "23503" { + return communitysuggestions.ErrSuggestionNotFound + } + if strings.Contains(pqErr.Constraint, "valid_vote_value") { + return communitysuggestions.ErrInvalidVoteValue + } + } + return fmt.Errorf("failed to insert vote: %w", err) + } + delta = value + } + + // Update the denormalized vote count and verify the suggestion still exists + updateCountQuery := ` + UPDATE community_suggestions + SET vote_count = vote_count + $1, updated_at = NOW() + WHERE id = $2 + ` + result, err := tx.ExecContext(ctx, updateCountQuery, delta, suggestionID) + if err != nil { + return fmt.Errorf("failed to update vote count: %w", err) + } + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("failed to check vote count update result: %w", err) + } + if rowsAffected == 0 { + return communitysuggestions.ErrSuggestionNotFound + } + + if err = tx.Commit(); err != nil { + return fmt.Errorf("failed to commit atomic vote transaction: %w", err) + } + + return nil +} + +// GetVote retrieves a single vote by suggestion ID and voter DID +// Returns ErrVoteNotFound if the vote does not exist +func (r *postgresCommunitySuggestionRepo) GetVote(ctx context.Context, suggestionID int64, voterDID string) (*communitysuggestions.SuggestionVote, error) { + query := ` + SELECT id, suggestion_id, voter_did, value, created_at + FROM suggestion_votes + WHERE suggestion_id = $1 AND voter_did = $2 + ` + + var vote communitysuggestions.SuggestionVote + err := r.db.QueryRowContext(ctx, query, suggestionID, voterDID).Scan( + &vote.ID, &vote.SuggestionID, &vote.VoterDID, + &vote.Value, &vote.CreatedAt, + ) + if err != nil { + if err == sql.ErrNoRows { + return nil, communitysuggestions.ErrVoteNotFound + } + return nil, fmt.Errorf("failed to get vote: %w", err) + } + + return &vote, nil +} + +// GetVotesForViewer retrieves the votes cast by a viewer on a set of suggestions +// Returns a map of suggestion ID to vote value +func (r *postgresCommunitySuggestionRepo) GetVotesForViewer(ctx context.Context, voterDID string, suggestionIDs []int64) (map[int64]int, error) { + if len(suggestionIDs) == 0 { + return make(map[int64]int), nil + } + + query := ` + SELECT suggestion_id, value + FROM suggestion_votes + WHERE voter_did = $1 AND suggestion_id = ANY($2) + ` + + rows, err := r.db.QueryContext(ctx, query, voterDID, pq.Array(suggestionIDs)) + if err != nil { + return nil, fmt.Errorf("failed to get votes for viewer: %w", err) + } + defer func() { + if closeErr := rows.Close(); closeErr != nil { + slog.Warn("failed to close rows in GetVotesForViewer", + slog.String("error", closeErr.Error()), + ) + } + }() + + votes := make(map[int64]int) + for rows.Next() { + var suggestionID int64 + var value int + if err := rows.Scan(&suggestionID, &value); err != nil { + return nil, fmt.Errorf("failed to scan vote for viewer: %w", err) + } + votes[suggestionID] = value + } + + if err = rows.Err(); err != nil { + return nil, fmt.Errorf("error iterating votes for viewer: %w", err) + } + + return votes, nil +} + +// scanSuggestion scans a single suggestion from a database row +func scanSuggestion(rows *sql.Rows) (*communitysuggestions.CommunitySuggestion, error) { + var suggestion communitysuggestions.CommunitySuggestion + var status string + + err := rows.Scan( + &suggestion.ID, &suggestion.Title, &suggestion.Description, + &suggestion.SubmitterDID, &status, + &suggestion.VoteCount, &suggestion.CreatedAt, &suggestion.UpdatedAt, + ) + if err != nil { + return nil, fmt.Errorf("failed to scan community suggestion: %w", err) + } + + suggestion.Status = communitysuggestions.Status(status) + return &suggestion, nil +} diff --git a/scripts/setup-mobile-ports.sh b/scripts/setup-mobile-ports.sh index 7fddab8..c6424d3 100755 --- a/scripts/setup-mobile-ports.sh +++ b/scripts/setup-mobile-ports.sh @@ -35,6 +35,7 @@ echo -e "${YELLOW}Setting up port forwarding...${NC}" adb reverse tcp:3000 tcp:3001 # PDS (internal port in DID document) adb reverse tcp:3001 tcp:3001 # PDS (external port) adb reverse tcp:3002 tcp:3002 # PLC Directory +adb reverse tcp:8080 tcp:8080 # Caddy proxy (OAuth callbacks route through here) adb reverse tcp:8081 tcp:8081 # AppView echo "" @@ -47,6 +48,7 @@ echo "" echo -e "${GREEN}PDS (3000):${NC} localhost:3001 → device:3000 ${YELLOW}(DID document port)${NC}" echo -e "${GREEN}PDS (3001):${NC} localhost:3001 → device:3001" echo -e "${GREEN}PLC (3002):${NC} localhost:3002 → device:3002" +echo -e "${GREEN}Caddy (8080):${NC} localhost:8080 → device:8080 ${YELLOW}(OAuth callbacks)${NC}" echo -e "${GREEN}AppView (8081):${NC} localhost:8081 → device:8081" echo "" echo -e "${CYAN}═══════════════════════════════════════════════════════════${NC}" diff --git a/tests/integration/community_suggestion_e2e_test.go b/tests/integration/community_suggestion_e2e_test.go new file mode 100644 index 0000000..ef91452 --- /dev/null +++ b/tests/integration/community_suggestion_e2e_test.go @@ -0,0 +1,1268 @@ +package integration + +import ( + "Coves/internal/api/routes" + "Coves/internal/core/communitysuggestions" + "Coves/internal/db/postgres" + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/go-chi/chi/v5" + _ "github.com/lib/pq" +) + +// --- Test helpers --- + +// suggestionResponse represents the JSON response for a single community suggestion +type suggestionResponse struct { + ID int64 `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + SubmitterDID string `json:"submitterDid"` + Status string `json:"status"` + VoteCount int `json:"voteCount"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` + Viewer *struct { + Vote *int `json:"vote"` + } `json:"viewer"` +} + +// listSuggestionsResponse represents the JSON response for listing suggestions +type listSuggestionsResponse struct { + Suggestions []suggestionResponse `json:"suggestions"` + Cursor string `json:"cursor"` +} + +// xrpcErrorResponse represents an XRPC error response +type xrpcErrorResponse struct { + Error string `json:"error"` + Message string `json:"message"` +} + +// createTestSuggestionRequest creates a suggestion via the HTTP API and returns the response recorder. +// Does NOT fail the test on non-200 responses so callers can assert specific error codes. +func createTestSuggestionRequest(t *testing.T, router http.Handler, token, title, description string) *httptest.ResponseRecorder { + t.Helper() + + body, err := json.Marshal(map[string]string{ + "title": title, + "description": description, + }) + if err != nil { + t.Fatalf("Failed to marshal create suggestion request: %v", err) + } + + req := httptest.NewRequest(http.MethodPost, + "/xrpc/social.coves.community.suggestion.create", + bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + return rec +} + +// mustCreateTestSuggestion creates a suggestion and fails the test if it doesn't succeed. +// Returns the decoded suggestion response. +func mustCreateTestSuggestion(t *testing.T, router http.Handler, token, title, description string) suggestionResponse { + t.Helper() + + rec := createTestSuggestionRequest(t, router, token, title, description) + if rec.Code != http.StatusOK { + t.Fatalf("Expected 200 creating suggestion, got %d: %s", rec.Code, rec.Body.String()) + } + + var resp suggestionResponse + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("Failed to decode create suggestion response: %v", err) + } + return resp +} + +// voteOnSuggestionRequest casts a vote via the HTTP API and returns the response recorder. +func voteOnSuggestionRequest(t *testing.T, router http.Handler, token string, suggestionID int64, value int) *httptest.ResponseRecorder { + t.Helper() + + body, err := json.Marshal(map[string]interface{}{ + "suggestionId": suggestionID, + "value": value, + }) + if err != nil { + t.Fatalf("Failed to marshal vote request: %v", err) + } + + req := httptest.NewRequest(http.MethodPost, + "/xrpc/social.coves.community.suggestion.vote", + bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + return rec +} + +// removeVoteRequest removes a vote via the HTTP API and returns the response recorder. +func removeVoteRequest(t *testing.T, router http.Handler, token string, suggestionID int64) *httptest.ResponseRecorder { + t.Helper() + + body, err := json.Marshal(map[string]interface{}{ + "suggestionId": suggestionID, + }) + if err != nil { + t.Fatalf("Failed to marshal remove vote request: %v", err) + } + + req := httptest.NewRequest(http.MethodPost, + "/xrpc/social.coves.community.suggestion.removeVote", + bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + return rec +} + +// getSuggestionRequest fetches a suggestion by ID via the HTTP API. +func getSuggestionRequest(t *testing.T, router http.Handler, token string, id int64) *httptest.ResponseRecorder { + t.Helper() + + url := fmt.Sprintf("/xrpc/social.coves.community.suggestion.get?id=%d", id) + req := httptest.NewRequest(http.MethodGet, url, nil) + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + return rec +} + +// listSuggestionsRequest lists suggestions via the HTTP API with query params. +func listSuggestionsRequest(t *testing.T, router http.Handler, token string, queryParams string) *httptest.ResponseRecorder { + t.Helper() + + url := "/xrpc/social.coves.community.suggestion.list" + if queryParams != "" { + url += "?" + queryParams + } + + req := httptest.NewRequest(http.MethodGet, url, nil) + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + return rec +} + +// updateStatusRequest updates a suggestion's status via the HTTP API. +func updateStatusRequest(t *testing.T, router http.Handler, token string, suggestionID int64, status string) *httptest.ResponseRecorder { + t.Helper() + + body, err := json.Marshal(map[string]interface{}{ + "suggestionId": suggestionID, + "status": status, + }) + if err != nil { + t.Fatalf("Failed to marshal update status request: %v", err) + } + + req := httptest.NewRequest(http.MethodPost, + "/xrpc/social.coves.community.suggestion.updateStatus", + bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + return rec +} + +// setupSuggestionTestRouter sets up a chi router with real community suggestion handlers, +// a real PostgreSQL repository, and a mock OAuth middleware for authentication injection. +// Returns the router, the E2EOAuthMiddleware (for adding users), and a cleanup function. +func setupSuggestionTestRouter(t *testing.T, adminDIDs []string) (http.Handler, *E2EOAuthMiddleware) { + t.Helper() + + db := setupTestDB(t) + + // Clean up suggestion-specific tables at the start to avoid dirty state from previous runs + if _, err := db.Exec("DELETE FROM suggestion_votes"); err != nil { + t.Logf("Warning: Failed to clean up suggestion_votes: %v", err) + } + if _, err := db.Exec("DELETE FROM community_suggestions"); err != nil { + t.Logf("Warning: Failed to clean up community_suggestions: %v", err) + } + + t.Cleanup(func() { + // Clean up at end too to leave DB clean + _, _ = db.Exec("DELETE FROM suggestion_votes") + _, _ = db.Exec("DELETE FROM community_suggestions") + _ = db.Close() + }) + + // Wire up real repository and service + repo := postgres.NewCommunitySuggestionRepository(db) + service := communitysuggestions.NewService(repo) + + // Create E2E OAuth middleware for injecting test users + e2eAuth := NewE2EOAuthMiddleware() + + // Set up chi router with real handlers via route registration + r := chi.NewRouter() + routes.RegisterCommunitySuggestionRoutes(r, service, e2eAuth.OAuthAuthMiddleware, adminDIDs) + + return r, e2eAuth +} + +// TestCommunitySuggestionE2E is the comprehensive E2E integration test for the +// Community Suggestions & Voting feature. It tests the full stack: +// HTTP handlers -> service -> repository -> PostgreSQL. +func TestCommunitySuggestionE2E(t *testing.T) { + if testing.Short() { + t.Skip("Skipping E2E test in short mode") + } + + adminDID := "did:plc:testadmin" + userDID := "did:plc:testuser1" + user2DID := "did:plc:testuser2" + user3DID := "did:plc:testuser3" + + router, e2eAuth := setupSuggestionTestRouter(t, []string{adminDID}) + + // Register test users with the mock auth system + _ = e2eAuth.AddUser(adminDID) // admin registered for shared router + userToken := e2eAuth.AddUser(userDID) + user2Token := e2eAuth.AddUser(user2DID) + _ = e2eAuth.AddUser(user3DID) // registered but used in subtests with own routers + + // ===================================================================== + // Test: Create Suggestion + // ===================================================================== + t.Run("Create suggestion", func(t *testing.T) { + rec := createTestSuggestionRequest(t, router, userToken, "Golang Community", "A place for Go developers to share and learn") + if rec.Code != http.StatusOK { + t.Fatalf("Expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + + var resp suggestionResponse + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + + if resp.ID <= 0 { + t.Errorf("Expected positive ID, got %d", resp.ID) + } + if resp.Title != "Golang Community" { + t.Errorf("Expected title 'Golang Community', got %q", resp.Title) + } + if resp.Description != "A place for Go developers to share and learn" { + t.Errorf("Expected description to match, got %q", resp.Description) + } + if resp.Status != "open" { + t.Errorf("Expected status 'open', got %q", resp.Status) + } + if resp.VoteCount != 0 { + t.Errorf("Expected voteCount 0, got %d", resp.VoteCount) + } + if resp.SubmitterDID != userDID { + t.Errorf("Expected submitterDid %q, got %q", userDID, resp.SubmitterDID) + } + if resp.CreatedAt == "" { + t.Error("Expected createdAt to be populated") + } + if resp.UpdatedAt == "" { + t.Error("Expected updatedAt to be populated") + } + }) + + // ===================================================================== + // Test: Create Suggestion - Validation Errors + // ===================================================================== + t.Run("Create suggestion - missing title", func(t *testing.T) { + rec := createTestSuggestionRequest(t, router, userToken, "", "Some description") + if rec.Code != http.StatusBadRequest { + t.Fatalf("Expected 400, got %d: %s", rec.Code, rec.Body.String()) + } + + var errResp xrpcErrorResponse + if err := json.NewDecoder(rec.Body).Decode(&errResp); err != nil { + t.Fatalf("Failed to decode error response: %v", err) + } + if errResp.Error != "InvalidRequest" { + t.Errorf("Expected error 'InvalidRequest', got %q", errResp.Error) + } + }) + + t.Run("Create suggestion - empty description", func(t *testing.T) { + rec := createTestSuggestionRequest(t, router, userToken, "Valid Title", "") + if rec.Code != http.StatusBadRequest { + t.Fatalf("Expected 400, got %d: %s", rec.Code, rec.Body.String()) + } + }) + + t.Run("Create suggestion - title too long", func(t *testing.T) { + longTitle := strings.Repeat("a", communitysuggestions.MaxTitleLength+1) + rec := createTestSuggestionRequest(t, router, userToken, longTitle, "Valid description") + if rec.Code != http.StatusBadRequest { + t.Fatalf("Expected 400, got %d: %s", rec.Code, rec.Body.String()) + } + + var errResp xrpcErrorResponse + if err := json.NewDecoder(rec.Body).Decode(&errResp); err != nil { + t.Fatalf("Failed to decode error response: %v", err) + } + if errResp.Error != "InvalidRequest" { + t.Errorf("Expected error 'InvalidRequest', got %q", errResp.Error) + } + }) + + // ===================================================================== + // Test: Create Suggestion - Auth Required + // ===================================================================== + t.Run("Create suggestion - auth required", func(t *testing.T) { + rec := createTestSuggestionRequest(t, router, "", "No Auth", "Should fail") + if rec.Code != http.StatusUnauthorized { + t.Fatalf("Expected 401, got %d: %s", rec.Code, rec.Body.String()) + } + }) + + // ===================================================================== + // Test: Get Suggestion by ID + // ===================================================================== + t.Run("Get suggestion by ID", func(t *testing.T) { + // Create a suggestion first + created := mustCreateTestSuggestion(t, router, user2Token, + "Get Test Community", "Community to test get endpoint") + + rec := getSuggestionRequest(t, router, user2Token, created.ID) + if rec.Code != http.StatusOK { + t.Fatalf("Expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + + var resp suggestionResponse + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + + if resp.ID != created.ID { + t.Errorf("Expected ID %d, got %d", created.ID, resp.ID) + } + if resp.Title != "Get Test Community" { + t.Errorf("Expected title 'Get Test Community', got %q", resp.Title) + } + if resp.Description != "Community to test get endpoint" { + t.Errorf("Expected description to match, got %q", resp.Description) + } + if resp.Status != "open" { + t.Errorf("Expected status 'open', got %q", resp.Status) + } + }) + + // ===================================================================== + // Test: Get Suggestion - Not Found + // ===================================================================== + t.Run("Get suggestion - not found", func(t *testing.T) { + rec := getSuggestionRequest(t, router, userToken, 999999) + if rec.Code != http.StatusNotFound { + t.Fatalf("Expected 404, got %d: %s", rec.Code, rec.Body.String()) + } + + var errResp xrpcErrorResponse + if err := json.NewDecoder(rec.Body).Decode(&errResp); err != nil { + t.Fatalf("Failed to decode error response: %v", err) + } + if errResp.Error != "NotFound" { + t.Errorf("Expected error 'NotFound', got %q", errResp.Error) + } + }) + + // ===================================================================== + // Test: List Suggestions - Default Sort (Popular) + // ===================================================================== + t.Run("List suggestions - default sort popular", func(t *testing.T) { + // Create a fresh router to avoid pollution from previous tests + listRouter, listAuth := setupSuggestionTestRouter(t, []string{adminDID}) + listUserToken := listAuth.AddUser(userDID) + listUser2Token := listAuth.AddUser(user2DID) + + // Create two suggestions + s1 := mustCreateTestSuggestion(t, listRouter, listUserToken, + "Popular Community", "Should be sorted by votes") + s2 := mustCreateTestSuggestion(t, listRouter, listUserToken, + "Less Popular Community", "Should appear after popular") + + // Vote on the first suggestion to make it more popular + voteRec := voteOnSuggestionRequest(t, listRouter, listUser2Token, s1.ID, 1) + if voteRec.Code != http.StatusOK { + t.Fatalf("Vote failed: %d: %s", voteRec.Code, voteRec.Body.String()) + } + + // List with default sort (popular) + rec := listSuggestionsRequest(t, listRouter, listUserToken, "") + if rec.Code != http.StatusOK { + t.Fatalf("Expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + + var resp listSuggestionsResponse + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + + if len(resp.Suggestions) < 2 { + t.Fatalf("Expected at least 2 suggestions, got %d", len(resp.Suggestions)) + } + + // First suggestion should be the one with votes (s1) + if resp.Suggestions[0].ID != s1.ID { + t.Errorf("Expected first suggestion ID %d (popular), got %d", s1.ID, resp.Suggestions[0].ID) + } + if resp.Suggestions[0].VoteCount != 1 { + t.Errorf("Expected first suggestion voteCount 1, got %d", resp.Suggestions[0].VoteCount) + } + + // Second suggestion should be the one without votes (s2) + found := false + for _, sg := range resp.Suggestions { + if sg.ID == s2.ID { + found = true + if sg.VoteCount != 0 { + t.Errorf("Expected s2 voteCount 0, got %d", sg.VoteCount) + } + break + } + } + if !found { + t.Error("Expected to find s2 in list results") + } + }) + + // ===================================================================== + // Test: List Suggestions - Sort by New + // ===================================================================== + t.Run("List suggestions - sort by new", func(t *testing.T) { + listRouter, listAuth := setupSuggestionTestRouter(t, []string{adminDID}) + listUserToken := listAuth.AddUser(userDID) + + // Create two suggestions with a small delay to ensure different timestamps + _ = mustCreateTestSuggestion(t, listRouter, listUserToken, + "Older Community", "Created first") + time.Sleep(10 * time.Millisecond) + s2 := mustCreateTestSuggestion(t, listRouter, listUserToken, + "Newer Community", "Created second") + + rec := listSuggestionsRequest(t, listRouter, listUserToken, "sort=new") + if rec.Code != http.StatusOK { + t.Fatalf("Expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + + var resp listSuggestionsResponse + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + + if len(resp.Suggestions) < 2 { + t.Fatalf("Expected at least 2 suggestions, got %d", len(resp.Suggestions)) + } + + // First suggestion should be the newest (s2) + if resp.Suggestions[0].ID != s2.ID { + t.Errorf("Expected first suggestion ID %d (newest), got %d", s2.ID, resp.Suggestions[0].ID) + } + }) + + // ===================================================================== + // Test: List Suggestions - Status Filter + // ===================================================================== + t.Run("List suggestions - status filter", func(t *testing.T) { + listRouter, listAuth := setupSuggestionTestRouter(t, []string{adminDID}) + listUserToken := listAuth.AddUser(userDID) + listAdminToken := listAuth.AddUser(adminDID) + + // Create two suggestions + s1 := mustCreateTestSuggestion(t, listRouter, listUserToken, + "Open Suggestion", "Should remain open") + _ = mustCreateTestSuggestion(t, listRouter, listUserToken, + "To Be Approved", "Will be updated to approved") + + // Update s1 status to approved via admin + statusRec := updateStatusRequest(t, listRouter, listAdminToken, s1.ID, "approved") + if statusRec.Code != http.StatusOK { + t.Fatalf("Status update failed: %d: %s", statusRec.Code, statusRec.Body.String()) + } + + // List only approved suggestions + rec := listSuggestionsRequest(t, listRouter, listUserToken, "status=approved") + if rec.Code != http.StatusOK { + t.Fatalf("Expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + + var resp listSuggestionsResponse + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + + // Should only contain approved suggestions + for _, sg := range resp.Suggestions { + if sg.Status != "approved" { + t.Errorf("Expected all suggestions to have status 'approved', got %q for ID %d", sg.Status, sg.ID) + } + } + + // Should have at least 1 result + if len(resp.Suggestions) == 0 { + t.Error("Expected at least 1 approved suggestion") + } + + // List only open suggestions + rec2 := listSuggestionsRequest(t, listRouter, listUserToken, "status=open") + if rec2.Code != http.StatusOK { + t.Fatalf("Expected 200, got %d: %s", rec2.Code, rec2.Body.String()) + } + + var resp2 listSuggestionsResponse + if err := json.NewDecoder(rec2.Body).Decode(&resp2); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + + for _, sg := range resp2.Suggestions { + if sg.Status != "open" { + t.Errorf("Expected all suggestions to have status 'open', got %q for ID %d", sg.Status, sg.ID) + } + } + }) + + // ===================================================================== + // Test: List Suggestions - Pagination + // ===================================================================== + t.Run("List suggestions - pagination", func(t *testing.T) { + listRouter, listAuth := setupSuggestionTestRouter(t, []string{adminDID}) + listUserToken := listAuth.AddUser(userDID) + + // Create 5 suggestions + for i := 0; i < 5; i++ { + mustCreateTestSuggestion(t, listRouter, listUserToken, + fmt.Sprintf("Pagination Test %d", i), + fmt.Sprintf("Description for pagination test %d", i)) + } + + // First page: limit=2 + rec := listSuggestionsRequest(t, listRouter, listUserToken, "sort=new&limit=2") + if rec.Code != http.StatusOK { + t.Fatalf("Expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + + var page1 listSuggestionsResponse + if err := json.NewDecoder(rec.Body).Decode(&page1); err != nil { + t.Fatalf("Failed to decode page 1: %v", err) + } + + if len(page1.Suggestions) != 2 { + t.Fatalf("Expected 2 suggestions on page 1, got %d", len(page1.Suggestions)) + } + + // Cursor should be non-empty since there are more results + if page1.Cursor == "" { + t.Fatal("Expected non-empty cursor on page 1") + } + + // Second page: use cursor from first page + rec2 := listSuggestionsRequest(t, listRouter, listUserToken, + fmt.Sprintf("sort=new&limit=2&cursor=%s", page1.Cursor)) + if rec2.Code != http.StatusOK { + t.Fatalf("Expected 200, got %d: %s", rec2.Code, rec2.Body.String()) + } + + var page2 listSuggestionsResponse + if err := json.NewDecoder(rec2.Body).Decode(&page2); err != nil { + t.Fatalf("Failed to decode page 2: %v", err) + } + + if len(page2.Suggestions) != 2 { + t.Fatalf("Expected 2 suggestions on page 2, got %d", len(page2.Suggestions)) + } + + // Ensure page 2 suggestions are different from page 1 + page1IDs := map[int64]bool{ + page1.Suggestions[0].ID: true, + page1.Suggestions[1].ID: true, + } + for _, sg := range page2.Suggestions { + if page1IDs[sg.ID] { + t.Errorf("Suggestion ID %d appeared on both page 1 and page 2", sg.ID) + } + } + + // Third page: should have 1 result and empty cursor + rec3 := listSuggestionsRequest(t, listRouter, listUserToken, + fmt.Sprintf("sort=new&limit=2&cursor=%s", page2.Cursor)) + if rec3.Code != http.StatusOK { + t.Fatalf("Expected 200, got %d: %s", rec3.Code, rec3.Body.String()) + } + + var page3 listSuggestionsResponse + if err := json.NewDecoder(rec3.Body).Decode(&page3); err != nil { + t.Fatalf("Failed to decode page 3: %v", err) + } + + if len(page3.Suggestions) != 1 { + t.Fatalf("Expected 1 suggestion on page 3, got %d", len(page3.Suggestions)) + } + + // Cursor should be empty since there are no more results + if page3.Cursor != "" { + t.Errorf("Expected empty cursor on last page, got %q", page3.Cursor) + } + }) + + // ===================================================================== + // Test: Vote on Suggestion + // ===================================================================== + t.Run("Vote on suggestion", func(t *testing.T) { + voteRouter, voteAuth := setupSuggestionTestRouter(t, []string{adminDID}) + voteUserToken := voteAuth.AddUser(userDID) + voteUser2Token := voteAuth.AddUser(user2DID) + + // Create a suggestion + created := mustCreateTestSuggestion(t, voteRouter, voteUserToken, + "Vote Test Community", "Testing voting functionality") + + // Vote +1 + rec := voteOnSuggestionRequest(t, voteRouter, voteUser2Token, created.ID, 1) + if rec.Code != http.StatusOK { + t.Fatalf("Expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + + // Verify vote_count incremented by getting the suggestion + getRec := getSuggestionRequest(t, voteRouter, voteUser2Token, created.ID) + if getRec.Code != http.StatusOK { + t.Fatalf("Expected 200, got %d: %s", getRec.Code, getRec.Body.String()) + } + + var getResp suggestionResponse + if err := json.NewDecoder(getRec.Body).Decode(&getResp); err != nil { + t.Fatalf("Failed to decode get response: %v", err) + } + + if getResp.VoteCount != 1 { + t.Errorf("Expected voteCount 1, got %d", getResp.VoteCount) + } + + // Verify viewer state shows vote = 1 for the voter + if getResp.Viewer == nil { + t.Fatal("Expected non-nil Viewer state for authenticated user") + } + if getResp.Viewer.Vote == nil { + t.Fatal("Expected non-nil Viewer.Vote for user who voted") + } + if *getResp.Viewer.Vote != 1 { + t.Errorf("Expected Viewer.Vote = 1, got %d", *getResp.Viewer.Vote) + } + }) + + // ===================================================================== + // Test: Vote Toggle (Same Direction Removes) + // ===================================================================== + t.Run("Vote toggle - same direction removes", func(t *testing.T) { + toggleRouter, toggleAuth := setupSuggestionTestRouter(t, []string{adminDID}) + toggleUserToken := toggleAuth.AddUser(userDID) + toggleUser2Token := toggleAuth.AddUser(user2DID) + + created := mustCreateTestSuggestion(t, toggleRouter, toggleUserToken, + "Toggle Test", "Testing vote toggle") + + // Vote +1 + rec1 := voteOnSuggestionRequest(t, toggleRouter, toggleUser2Token, created.ID, 1) + if rec1.Code != http.StatusOK { + t.Fatalf("First vote failed: %d: %s", rec1.Code, rec1.Body.String()) + } + + // Vote +1 again (should toggle off) + rec2 := voteOnSuggestionRequest(t, toggleRouter, toggleUser2Token, created.ID, 1) + if rec2.Code != http.StatusOK { + t.Fatalf("Toggle vote failed: %d: %s", rec2.Code, rec2.Body.String()) + } + + // Verify vote_count is back to 0 + getRec := getSuggestionRequest(t, toggleRouter, toggleUser2Token, created.ID) + if getRec.Code != http.StatusOK { + t.Fatalf("Expected 200, got %d: %s", getRec.Code, getRec.Body.String()) + } + + var getResp suggestionResponse + if err := json.NewDecoder(getRec.Body).Decode(&getResp); err != nil { + t.Fatalf("Failed to decode get response: %v", err) + } + + if getResp.VoteCount != 0 { + t.Errorf("Expected voteCount 0 after toggle, got %d", getResp.VoteCount) + } + + // Viewer state should NOT show a vote (vote was removed) + if getResp.Viewer != nil && getResp.Viewer.Vote != nil { + t.Errorf("Expected nil Viewer.Vote after toggle, got %d", *getResp.Viewer.Vote) + } + }) + + // ===================================================================== + // Test: Vote Flip (Opposite Direction Changes) + // ===================================================================== + t.Run("Vote flip - opposite direction changes", func(t *testing.T) { + flipRouter, flipAuth := setupSuggestionTestRouter(t, []string{adminDID}) + flipUserToken := flipAuth.AddUser(userDID) + flipUser2Token := flipAuth.AddUser(user2DID) + + created := mustCreateTestSuggestion(t, flipRouter, flipUserToken, + "Flip Test", "Testing vote flip") + + // Vote +1 + rec1 := voteOnSuggestionRequest(t, flipRouter, flipUser2Token, created.ID, 1) + if rec1.Code != http.StatusOK { + t.Fatalf("First vote failed: %d: %s", rec1.Code, rec1.Body.String()) + } + + // Vote -1 (should flip) + rec2 := voteOnSuggestionRequest(t, flipRouter, flipUser2Token, created.ID, -1) + if rec2.Code != http.StatusOK { + t.Fatalf("Flip vote failed: %d: %s", rec2.Code, rec2.Body.String()) + } + + // Verify vote_count is -1 + getRec := getSuggestionRequest(t, flipRouter, flipUser2Token, created.ID) + if getRec.Code != http.StatusOK { + t.Fatalf("Expected 200, got %d: %s", getRec.Code, getRec.Body.String()) + } + + var getResp suggestionResponse + if err := json.NewDecoder(getRec.Body).Decode(&getResp); err != nil { + t.Fatalf("Failed to decode get response: %v", err) + } + + if getResp.VoteCount != -1 { + t.Errorf("Expected voteCount -1 after flip, got %d", getResp.VoteCount) + } + + // Viewer state should show vote = -1 + if getResp.Viewer == nil || getResp.Viewer.Vote == nil { + t.Fatal("Expected non-nil Viewer.Vote after flip") + } + if *getResp.Viewer.Vote != -1 { + t.Errorf("Expected Viewer.Vote = -1, got %d", *getResp.Viewer.Vote) + } + }) + + // ===================================================================== + // Test: Remove Vote Explicitly + // ===================================================================== + t.Run("Remove vote explicitly", func(t *testing.T) { + removeRouter, removeAuth := setupSuggestionTestRouter(t, []string{adminDID}) + removeUserToken := removeAuth.AddUser(userDID) + removeUser2Token := removeAuth.AddUser(user2DID) + + created := mustCreateTestSuggestion(t, removeRouter, removeUserToken, + "Remove Vote Test", "Testing explicit vote removal") + + // Vote +1 + rec1 := voteOnSuggestionRequest(t, removeRouter, removeUser2Token, created.ID, 1) + if rec1.Code != http.StatusOK { + t.Fatalf("Vote failed: %d: %s", rec1.Code, rec1.Body.String()) + } + + // Verify vote_count is 1 + getRec := getSuggestionRequest(t, removeRouter, removeUser2Token, created.ID) + var beforeResp suggestionResponse + if err := json.NewDecoder(getRec.Body).Decode(&beforeResp); err != nil { + t.Fatalf("Failed to decode: %v", err) + } + if beforeResp.VoteCount != 1 { + t.Fatalf("Expected voteCount 1 before removal, got %d", beforeResp.VoteCount) + } + + // Explicitly remove vote + removeRec := removeVoteRequest(t, removeRouter, removeUser2Token, created.ID) + if removeRec.Code != http.StatusOK { + t.Fatalf("Remove vote failed: %d: %s", removeRec.Code, removeRec.Body.String()) + } + + // Verify vote_count is back to 0 + getRec2 := getSuggestionRequest(t, removeRouter, removeUser2Token, created.ID) + if getRec2.Code != http.StatusOK { + t.Fatalf("Expected 200, got %d: %s", getRec2.Code, getRec2.Body.String()) + } + + var afterResp suggestionResponse + if err := json.NewDecoder(getRec2.Body).Decode(&afterResp); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + + if afterResp.VoteCount != 0 { + t.Errorf("Expected voteCount 0 after removal, got %d", afterResp.VoteCount) + } + }) + + // ===================================================================== + // Test: Vote - Suggestion Not Found + // ===================================================================== + t.Run("Vote - suggestion not found", func(t *testing.T) { + rec := voteOnSuggestionRequest(t, router, userToken, 999999, 1) + if rec.Code != http.StatusNotFound { + t.Fatalf("Expected 404, got %d: %s", rec.Code, rec.Body.String()) + } + + var errResp xrpcErrorResponse + if err := json.NewDecoder(rec.Body).Decode(&errResp); err != nil { + t.Fatalf("Failed to decode error response: %v", err) + } + if errResp.Error != "NotFound" { + t.Errorf("Expected error 'NotFound', got %q", errResp.Error) + } + }) + + // ===================================================================== + // Test: Update Status - Admin + // ===================================================================== + t.Run("Update status - admin", func(t *testing.T) { + statusRouter, statusAuth := setupSuggestionTestRouter(t, []string{adminDID}) + statusUserToken := statusAuth.AddUser(userDID) + statusAdminToken := statusAuth.AddUser(adminDID) + + created := mustCreateTestSuggestion(t, statusRouter, statusUserToken, + "Status Update Test", "Testing admin status update") + + // Admin updates status to approved + rec := updateStatusRequest(t, statusRouter, statusAdminToken, created.ID, "approved") + if rec.Code != http.StatusOK { + t.Fatalf("Expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + + // Verify status changed + getRec := getSuggestionRequest(t, statusRouter, statusUserToken, created.ID) + if getRec.Code != http.StatusOK { + t.Fatalf("Expected 200, got %d: %s", getRec.Code, getRec.Body.String()) + } + + var getResp suggestionResponse + if err := json.NewDecoder(getRec.Body).Decode(&getResp); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + + if getResp.Status != "approved" { + t.Errorf("Expected status 'approved', got %q", getResp.Status) + } + }) + + // ===================================================================== + // Test: Update Status - Non-Admin Forbidden + // ===================================================================== + t.Run("Update status - non-admin forbidden", func(t *testing.T) { + statusRouter, statusAuth := setupSuggestionTestRouter(t, []string{adminDID}) + statusUserToken := statusAuth.AddUser(userDID) + + created := mustCreateTestSuggestion(t, statusRouter, statusUserToken, + "Forbidden Status Test", "Non-admin should not update") + + // Non-admin tries to update status + rec := updateStatusRequest(t, statusRouter, statusUserToken, created.ID, "approved") + if rec.Code != http.StatusForbidden { + t.Fatalf("Expected 403, got %d: %s", rec.Code, rec.Body.String()) + } + + var errResp xrpcErrorResponse + if err := json.NewDecoder(rec.Body).Decode(&errResp); err != nil { + t.Fatalf("Failed to decode error response: %v", err) + } + if errResp.Error != "Forbidden" { + t.Errorf("Expected error 'Forbidden', got %q", errResp.Error) + } + }) + + // ===================================================================== + // Test: Rate Limiting (3 suggestions per day per DID) + // ===================================================================== + t.Run("Rate limiting - max suggestions per day", func(t *testing.T) { + rlRouter, rlAuth := setupSuggestionTestRouter(t, []string{adminDID}) + rlUserToken := rlAuth.AddUser(user3DID) + + // Create MaxSuggestionsPerDay suggestions (should succeed) + for i := 0; i < communitysuggestions.MaxSuggestionsPerDay; i++ { + rec := createTestSuggestionRequest(t, rlRouter, rlUserToken, + fmt.Sprintf("Rate Limit Test %d", i), + fmt.Sprintf("Description %d", i)) + if rec.Code != http.StatusOK { + t.Fatalf("Expected 200 for suggestion %d, got %d: %s", i, rec.Code, rec.Body.String()) + } + } + + // Try to create one more (should fail with 429) + rec := createTestSuggestionRequest(t, rlRouter, rlUserToken, + "Over Limit", "Should be rate limited") + if rec.Code != http.StatusTooManyRequests { + t.Fatalf("Expected 429, got %d: %s", rec.Code, rec.Body.String()) + } + + var errResp xrpcErrorResponse + if err := json.NewDecoder(rec.Body).Decode(&errResp); err != nil { + t.Fatalf("Failed to decode error response: %v", err) + } + if errResp.Error != "RateLimitExceeded" { + t.Errorf("Expected error 'RateLimitExceeded', got %q", errResp.Error) + } + }) + + // ===================================================================== + // Test: List with Viewer State - Authenticated + // ===================================================================== + t.Run("List suggestions - viewer state populated for authenticated user", func(t *testing.T) { + vsRouter, vsAuth := setupSuggestionTestRouter(t, []string{adminDID}) + vsUserToken := vsAuth.AddUser(userDID) + vsUser2Token := vsAuth.AddUser(user2DID) + + // Create two suggestions + s1 := mustCreateTestSuggestion(t, vsRouter, vsUserToken, + "Viewer State Test 1", "User will vote on this") + _ = mustCreateTestSuggestion(t, vsRouter, vsUserToken, + "Viewer State Test 2", "User will not vote on this") + + // User2 votes on s1 + voteRec := voteOnSuggestionRequest(t, vsRouter, vsUser2Token, s1.ID, 1) + if voteRec.Code != http.StatusOK { + t.Fatalf("Vote failed: %d: %s", voteRec.Code, voteRec.Body.String()) + } + + // List as user2 (should see voter state) + rec := listSuggestionsRequest(t, vsRouter, vsUser2Token, "sort=new") + if rec.Code != http.StatusOK { + t.Fatalf("Expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + + var resp listSuggestionsResponse + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + + // Find s1 in the list and verify viewer state + var foundS1 bool + for _, sg := range resp.Suggestions { + if sg.ID == s1.ID { + foundS1 = true + if sg.Viewer == nil || sg.Viewer.Vote == nil { + t.Error("Expected non-nil Viewer.Vote for voted suggestion in list") + } else if *sg.Viewer.Vote != 1 { + t.Errorf("Expected Viewer.Vote = 1, got %d", *sg.Viewer.Vote) + } + } + } + if !foundS1 { + t.Error("Expected to find s1 in list response") + } + }) + + // ===================================================================== + // Test: List without Auth - No Viewer State + // ===================================================================== + t.Run("List suggestions - no viewer state for unauthenticated", func(t *testing.T) { + noAuthRouter, noAuthAuth := setupSuggestionTestRouter(t, []string{adminDID}) + noAuthUserToken := noAuthAuth.AddUser(userDID) + + mustCreateTestSuggestion(t, noAuthRouter, noAuthUserToken, + "No Auth Viewer Test", "No viewer state expected") + + // List without auth token + rec := listSuggestionsRequest(t, noAuthRouter, "", "sort=new") + if rec.Code != http.StatusOK { + t.Fatalf("Expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + + var resp listSuggestionsResponse + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + + for _, sg := range resp.Suggestions { + if sg.Viewer != nil { + t.Errorf("Expected nil Viewer for unauthenticated request, got non-nil for ID %d", sg.ID) + } + } + }) + + // ===================================================================== + // Test: Update Status - All Valid Statuses + // ===================================================================== + t.Run("Update status - all valid transitions", func(t *testing.T) { + allStatusRouter, allStatusAuth := setupSuggestionTestRouter(t, []string{adminDID}) + allStatusUserToken := allStatusAuth.AddUser(userDID) + allStatusAdminToken := allStatusAuth.AddUser(adminDID) + + validStatuses := []string{"under_review", "approved", "declined", "open"} + + for _, status := range validStatuses { + created := mustCreateTestSuggestion(t, allStatusRouter, allStatusUserToken, + fmt.Sprintf("Status %s Test", status), + fmt.Sprintf("Testing transition to %s", status)) + + rec := updateStatusRequest(t, allStatusRouter, allStatusAdminToken, created.ID, status) + if rec.Code != http.StatusOK { + t.Errorf("Expected 200 for status %q, got %d: %s", status, rec.Code, rec.Body.String()) + } + + // Verify + getRec := getSuggestionRequest(t, allStatusRouter, allStatusUserToken, created.ID) + var getResp suggestionResponse + if err := json.NewDecoder(getRec.Body).Decode(&getResp); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + if getResp.Status != status { + t.Errorf("Expected status %q, got %q", status, getResp.Status) + } + } + }) + + // ===================================================================== + // Test: Multiple Users Voting + // ===================================================================== + t.Run("Multiple users voting on same suggestion", func(t *testing.T) { + multiRouter, multiAuth := setupSuggestionTestRouter(t, []string{adminDID}) + multiUserToken := multiAuth.AddUser(userDID) + multiUser2Token := multiAuth.AddUser(user2DID) + multiUser3Token := multiAuth.AddUser(user3DID) + + created := mustCreateTestSuggestion(t, multiRouter, multiUserToken, + "Multi Vote Test", "Multiple users vote here") + + // User 1 votes +1 + rec1 := voteOnSuggestionRequest(t, multiRouter, multiUserToken, created.ID, 1) + if rec1.Code != http.StatusOK { + t.Fatalf("User1 vote failed: %d: %s", rec1.Code, rec1.Body.String()) + } + + // User 2 votes +1 + rec2 := voteOnSuggestionRequest(t, multiRouter, multiUser2Token, created.ID, 1) + if rec2.Code != http.StatusOK { + t.Fatalf("User2 vote failed: %d: %s", rec2.Code, rec2.Body.String()) + } + + // User 3 votes -1 + rec3 := voteOnSuggestionRequest(t, multiRouter, multiUser3Token, created.ID, -1) + if rec3.Code != http.StatusOK { + t.Fatalf("User3 vote failed: %d: %s", rec3.Code, rec3.Body.String()) + } + + // Verify total: +1 +1 -1 = +1 + getRec := getSuggestionRequest(t, multiRouter, multiUserToken, created.ID) + var getResp suggestionResponse + if err := json.NewDecoder(getRec.Body).Decode(&getResp); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + + if getResp.VoteCount != 1 { + t.Errorf("Expected voteCount 1 (from +1 +1 -1), got %d", getResp.VoteCount) + } + + // Verify viewer state for user1 (voted +1) + if getResp.Viewer == nil || getResp.Viewer.Vote == nil { + t.Fatal("Expected non-nil Viewer.Vote for user1") + } + if *getResp.Viewer.Vote != 1 { + t.Errorf("Expected Viewer.Vote = 1 for user1, got %d", *getResp.Viewer.Vote) + } + }) + + // ===================================================================== + // Test: Vote Auth Required + // ===================================================================== + t.Run("Vote - auth required", func(t *testing.T) { + // Try to vote without auth + rec := voteOnSuggestionRequest(t, router, "", 1, 1) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("Expected 401 for unauthenticated vote, got %d: %s", rec.Code, rec.Body.String()) + } + }) + + // ===================================================================== + // Test: Remove Vote Auth Required + // ===================================================================== + t.Run("Remove vote - auth required", func(t *testing.T) { + rec := removeVoteRequest(t, router, "", 1) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("Expected 401 for unauthenticated remove vote, got %d: %s", rec.Code, rec.Body.String()) + } + }) + + // ===================================================================== + // Test: Update Status Auth Required + // ===================================================================== + t.Run("Update status - auth required", func(t *testing.T) { + rec := updateStatusRequest(t, router, "", 1, "approved") + if rec.Code != http.StatusUnauthorized { + t.Fatalf("Expected 401 for unauthenticated status update, got %d: %s", rec.Code, rec.Body.String()) + } + }) +} + +// TestCommunitySuggestionE2E_ViewerStateOnGet tests that the get endpoint properly +// populates viewer state for authenticated users. +func TestCommunitySuggestionE2E_ViewerStateOnGet(t *testing.T) { + if testing.Short() { + t.Skip("Skipping E2E test in short mode") + } + + adminDID := "did:plc:testadmin" + userDID := "did:plc:vieweruser1" + user2DID := "did:plc:vieweruser2" + + router, e2eAuth := setupSuggestionTestRouter(t, []string{adminDID}) + userToken := e2eAuth.AddUser(userDID) + user2Token := e2eAuth.AddUser(user2DID) + + // Create a suggestion + created := mustCreateTestSuggestion(t, router, userToken, + "Viewer Get Test", "Testing viewer state on get") + + // User2 votes +1 + voteRec := voteOnSuggestionRequest(t, router, user2Token, created.ID, 1) + if voteRec.Code != http.StatusOK { + t.Fatalf("Vote failed: %d: %s", voteRec.Code, voteRec.Body.String()) + } + + t.Run("Voter sees their vote in viewer state", func(t *testing.T) { + getRec := getSuggestionRequest(t, router, user2Token, created.ID) + if getRec.Code != http.StatusOK { + t.Fatalf("Expected 200, got %d: %s", getRec.Code, getRec.Body.String()) + } + + var resp suggestionResponse + if err := json.NewDecoder(getRec.Body).Decode(&resp); err != nil { + t.Fatalf("Failed to decode: %v", err) + } + + if resp.Viewer == nil || resp.Viewer.Vote == nil { + t.Fatal("Expected non-nil Viewer.Vote for voter") + } + if *resp.Viewer.Vote != 1 { + t.Errorf("Expected Viewer.Vote = 1, got %d", *resp.Viewer.Vote) + } + }) + + t.Run("Non-voter sees no vote in viewer state", func(t *testing.T) { + // User1 did NOT vote, should see nil viewer.vote + getRec := getSuggestionRequest(t, router, userToken, created.ID) + if getRec.Code != http.StatusOK { + t.Fatalf("Expected 200, got %d: %s", getRec.Code, getRec.Body.String()) + } + + var resp suggestionResponse + if err := json.NewDecoder(getRec.Body).Decode(&resp); err != nil { + t.Fatalf("Failed to decode: %v", err) + } + + // Viewer state should be nil since user1 didn't vote + if resp.Viewer != nil && resp.Viewer.Vote != nil { + t.Errorf("Expected nil Viewer.Vote for non-voter, got %d", *resp.Viewer.Vote) + } + }) + + t.Run("Unauthenticated user sees no viewer state", func(t *testing.T) { + getRec := getSuggestionRequest(t, router, "", created.ID) + if getRec.Code != http.StatusOK { + t.Fatalf("Expected 200, got %d: %s", getRec.Code, getRec.Body.String()) + } + + var resp suggestionResponse + if err := json.NewDecoder(getRec.Body).Decode(&resp); err != nil { + t.Fatalf("Failed to decode: %v", err) + } + + if resp.Viewer != nil { + t.Error("Expected nil Viewer for unauthenticated request") + } + }) +} + +// TestCommunitySuggestionE2E_DownvoteFlow tests the full downvote lifecycle: +// downvote, toggle off, then upvote. +func TestCommunitySuggestionE2E_DownvoteFlow(t *testing.T) { + if testing.Short() { + t.Skip("Skipping E2E test in short mode") + } + + adminDID := "did:plc:testadmin" + userDID := "did:plc:downvoteuser1" + voterDID := "did:plc:downvotevoter1" + + router, e2eAuth := setupSuggestionTestRouter(t, []string{adminDID}) + userToken := e2eAuth.AddUser(userDID) + voterToken := e2eAuth.AddUser(voterDID) + + created := mustCreateTestSuggestion(t, router, userToken, + "Downvote Flow Test", "Testing downvote lifecycle") + + // Step 1: Downvote (-1) + rec1 := voteOnSuggestionRequest(t, router, voterToken, created.ID, -1) + if rec1.Code != http.StatusOK { + t.Fatalf("Downvote failed: %d: %s", rec1.Code, rec1.Body.String()) + } + + getRec1 := getSuggestionRequest(t, router, voterToken, created.ID) + var resp1 suggestionResponse + if err := json.NewDecoder(getRec1.Body).Decode(&resp1); err != nil { + t.Fatalf("Failed to decode: %v", err) + } + if resp1.VoteCount != -1 { + t.Errorf("Step 1: Expected voteCount -1, got %d", resp1.VoteCount) + } + if resp1.Viewer == nil || resp1.Viewer.Vote == nil || *resp1.Viewer.Vote != -1 { + t.Error("Step 1: Expected Viewer.Vote = -1") + } + + // Step 2: Downvote again (toggle off) + rec2 := voteOnSuggestionRequest(t, router, voterToken, created.ID, -1) + if rec2.Code != http.StatusOK { + t.Fatalf("Toggle downvote failed: %d: %s", rec2.Code, rec2.Body.String()) + } + + getRec2 := getSuggestionRequest(t, router, voterToken, created.ID) + var resp2 suggestionResponse + if err := json.NewDecoder(getRec2.Body).Decode(&resp2); err != nil { + t.Fatalf("Failed to decode: %v", err) + } + if resp2.VoteCount != 0 { + t.Errorf("Step 2: Expected voteCount 0, got %d", resp2.VoteCount) + } + + // Step 3: Upvote (+1) after removing downvote + rec3 := voteOnSuggestionRequest(t, router, voterToken, created.ID, 1) + if rec3.Code != http.StatusOK { + t.Fatalf("Upvote failed: %d: %s", rec3.Code, rec3.Body.String()) + } + + getRec3 := getSuggestionRequest(t, router, voterToken, created.ID) + var resp3 suggestionResponse + if err := json.NewDecoder(getRec3.Body).Decode(&resp3); err != nil { + t.Fatalf("Failed to decode: %v", err) + } + if resp3.VoteCount != 1 { + t.Errorf("Step 3: Expected voteCount 1, got %d", resp3.VoteCount) + } + if resp3.Viewer == nil || resp3.Viewer.Vote == nil || *resp3.Viewer.Vote != 1 { + t.Error("Step 3: Expected Viewer.Vote = 1") + } +} -- 2.51.2