diff --git a/cmd/server/main.go b/cmd/server/main.go index 383043f..93f5543 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -39,6 +39,7 @@ import ( "Coves/internal/core/posts" "Coves/internal/core/timeline" "Coves/internal/core/unfurl" + "Coves/internal/core/adminreports" "Coves/internal/core/users" "Coves/internal/core/votes" @@ -566,6 +567,11 @@ func main() { commentService := comments.NewCommentService(commentRepo, userRepo, postRepo, communityRepo, oauthClient, oauthStore, nil) log.Println("✅ Comment service initialized (with author/community hydration and write support)") + // Initialize admin report service (off-protocol reporting for serious content issues) + adminReportRepo := postgresRepo.NewAdminReportRepository(db) + adminReportService := adminreports.NewService(adminReportRepo) + log.Println("✅ Admin report service initialized (for flagging serious content)") + // Initialize feed service feedRepo := postgresRepo.NewCommunityFeedRepository(db, cursorSecret) feedService := communityFeeds.NewCommunityFeedService(feedRepo, communityService) @@ -751,6 +757,11 @@ func main() { log.Println(" - POST /xrpc/social.coves.community.comment.update") log.Println(" - POST /xrpc/social.coves.community.comment.delete") + // Register admin report routes (off-protocol content flagging) + routes.RegisterAdminReportRoutes(r, adminReportService, authMiddleware) + log.Println("✅ Admin report endpoint registered (requires OAuth)") + log.Println(" - POST /xrpc/social.coves.admin.submitReport") + 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 new file mode 100644 index 0000000..79f285d --- /dev/null +++ b/internal/api/handlers/adminreport/errors.go @@ -0,0 +1,70 @@ +package adminreport + +import ( + "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) + } +} + +// 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", + "Invalid report reason. Must be one of: csam, doxing, harassment, spam, illegal, other") + case errors.Is(err, adminreports.ErrInvalidStatus): + writeError(w, http.StatusBadRequest, "InvalidStatus", + "Invalid report status. Must be one of: open, reviewing, resolved, dismissed") + case errors.Is(err, adminreports.ErrInvalidTarget): + writeError(w, http.StatusBadRequest, "InvalidTarget", + "Invalid target URI. Must be a valid AT Protocol URI starting with at://") + case errors.Is(err, adminreports.ErrExplanationTooLong): + writeError(w, http.StatusBadRequest, "ExplanationTooLong", + "Explanation exceeds maximum length of 1000 characters") + case errors.Is(err, adminreports.ErrReporterRequired): + writeError(w, http.StatusBadRequest, "ReporterRequired", + "Reporter DID is required") + case errors.Is(err, adminreports.ErrInvalidTargetType): + 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") + } + + case adminreports.IsNotFound(err): + 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.go b/internal/api/handlers/adminreport/submit.go new file mode 100644 index 0000000..0b436ca --- /dev/null +++ b/internal/api/handlers/adminreport/submit.go @@ -0,0 +1,93 @@ +package adminreport + +import ( + "Coves/internal/api/middleware" + "Coves/internal/core/adminreports" + "encoding/json" + "log" + "net/http" +) + +// SubmitHandler handles report submission requests +type SubmitHandler struct { + service adminreports.Service +} + +// NewSubmitHandler creates a new handler for submitting admin reports +func NewSubmitHandler(service adminreports.Service) *SubmitHandler { + return &SubmitHandler{ + service: service, + } +} + +// SubmitReportInput matches the lexicon input schema for social.coves.admin.submitReport +type SubmitReportInput struct { + TargetURI string `json:"targetUri"` + Reason string `json:"reason"` + Explanation string `json:"explanation"` +} + +// SubmitReportOutput matches the lexicon output schema +type SubmitReportOutput struct { + Success bool `json:"success"` + ReportID int64 `json:"reportId"` +} + +// HandleSubmit handles report submission requests +// POST /xrpc/social.coves.admin.submitReport +// +// Request body: { "targetUri": "at://...", "reason": "csam", "explanation": "..." } +// Response: { "success": true, "reportId": 123 } +func (h *SubmitHandler) HandleSubmit(w http.ResponseWriter, r *http.Request) { + // 1. Check method is POST + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // 2. Limit request body size to 10KB to prevent DoS attacks + r.Body = http.MaxBytesReader(w, r.Body, 10*1024) + + // 3. Parse JSON body into SubmitReportInput + var input SubmitReportInput + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + // Log the decode error for debugging (but don't expose to client) + log.Printf("[ADMIN_REPORT] Failed to decode JSON request: %v", err) + writeError(w, http.StatusBadRequest, "InvalidRequest", "Invalid request body") + return + } + + // 4. Get user DID from context (injected by auth middleware) + userDID := middleware.GetUserDID(r) + if userDID == "" { + writeError(w, http.StatusUnauthorized, "AuthRequired", "Authentication required") + return + } + + // 5. Convert input to SubmitReportRequest + req := adminreports.SubmitReportRequest{ + ReporterDID: userDID, + TargetURI: input.TargetURI, + Reason: input.Reason, + Explanation: input.Explanation, + } + + // 6. Call service to submit report + result, err := h.service.SubmitReport(r.Context(), req) + if err != nil { + handleServiceError(w, err) + return + } + + // 7. Return JSON response indicating success with report ID + output := SubmitReportOutput{ + Success: true, + ReportID: result.ReportID, + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + if err := json.NewEncoder(w).Encode(output); err != nil { + log.Printf("Failed to encode response: %v", err) + } +} diff --git a/internal/api/handlers/adminreport/submit_test.go b/internal/api/handlers/adminreport/submit_test.go new file mode 100644 index 0000000..f305e7a --- /dev/null +++ b/internal/api/handlers/adminreport/submit_test.go @@ -0,0 +1,514 @@ +package adminreport + +import ( + "Coves/internal/api/middleware" + "Coves/internal/core/adminreports" + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// mockService implements adminreports.Service for testing +type mockService struct { + submitReportFunc func(ctx context.Context, req adminreports.SubmitReportRequest) (*adminreports.SubmitReportResult, error) +} + +func (m *mockService) SubmitReport(ctx context.Context, req adminreports.SubmitReportRequest) (*adminreports.SubmitReportResult, error) { + if m.submitReportFunc != nil { + return m.submitReportFunc(ctx, req) + } + return &adminreports.SubmitReportResult{ReportID: 1}, nil +} + +func TestHandleSubmit_Success(t *testing.T) { + svc := &mockService{ + submitReportFunc: func(ctx context.Context, req adminreports.SubmitReportRequest) (*adminreports.SubmitReportResult, error) { + if req.ReporterDID != "did:plc:testuser123" { + t.Errorf("expected ReporterDID %q, got %q", "did:plc:testuser123", req.ReporterDID) + } + if req.TargetURI != "at://did:plc:author123/social.coves.post/abc123" { + t.Errorf("expected TargetURI %q, got %q", "at://did:plc:author123/social.coves.post/abc123", req.TargetURI) + } + if req.Reason != "spam" { + t.Errorf("expected Reason %q, got %q", "spam", req.Reason) + } + if req.Explanation != "This is spam" { + t.Errorf("expected Explanation %q, got %q", "This is spam", req.Explanation) + } + return &adminreports.SubmitReportResult{ReportID: 42}, nil + }, + } + handler := NewSubmitHandler(svc) + + input := SubmitReportInput{ + TargetURI: "at://did:plc:author123/social.coves.post/abc123", + Reason: "spam", + Explanation: "This is spam", + } + body, _ := json.Marshal(input) + + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.admin.submitReport", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req = setTestUserDID(req, "did:plc:testuser123") + + w := httptest.NewRecorder() + handler.HandleSubmit(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected status %d, got %d", http.StatusOK, w.Code) + } + + var output SubmitReportOutput + if err := json.Unmarshal(w.Body.Bytes(), &output); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + + if !output.Success { + t.Error("expected Success to be true") + } + if output.ReportID != 42 { + t.Errorf("expected ReportID 42, got %d", output.ReportID) + } +} + +func TestHandleSubmit_MethodNotAllowed(t *testing.T) { + handler := NewSubmitHandler(&mockService{}) + + methods := []string{http.MethodGet, http.MethodPut, http.MethodDelete, http.MethodPatch} + for _, method := range methods { + t.Run(method, func(t *testing.T) { + req := httptest.NewRequest(method, "/xrpc/social.coves.admin.submitReport", nil) + req = setTestUserDID(req, "did:plc:testuser123") + + w := httptest.NewRecorder() + handler.HandleSubmit(w, req) + + if w.Code != http.StatusMethodNotAllowed { + t.Errorf("expected status %d, got %d", http.StatusMethodNotAllowed, w.Code) + } + }) + } +} + +func TestHandleSubmit_Unauthenticated(t *testing.T) { + handler := NewSubmitHandler(&mockService{}) + + input := SubmitReportInput{ + TargetURI: "at://did:plc:author123/social.coves.post/abc123", + Reason: "spam", + Explanation: "This is spam", + } + body, _ := json.Marshal(input) + + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.admin.submitReport", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + // No auth context - simulates unauthenticated request + + w := httptest.NewRecorder() + handler.HandleSubmit(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("expected status %d, got %d", http.StatusUnauthorized, w.Code) + } + + if !strings.Contains(w.Body.String(), "AuthRequired") { + t.Errorf("expected AuthRequired error, got %s", w.Body.String()) + } +} + +func TestHandleSubmit_InvalidJSON(t *testing.T) { + handler := NewSubmitHandler(&mockService{}) + + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.admin.submitReport", strings.NewReader("not valid json")) + req.Header.Set("Content-Type", "application/json") + req = setTestUserDID(req, "did:plc:testuser123") + + w := httptest.NewRecorder() + handler.HandleSubmit(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected status %d, got %d", http.StatusBadRequest, w.Code) + } + + if !strings.Contains(w.Body.String(), "InvalidRequest") { + t.Errorf("expected InvalidRequest error, got %s", w.Body.String()) + } +} + +func TestHandleSubmit_InvalidReason(t *testing.T) { + svc := &mockService{ + submitReportFunc: func(ctx context.Context, req adminreports.SubmitReportRequest) (*adminreports.SubmitReportResult, error) { + return nil, adminreports.ErrInvalidReason + }, + } + handler := NewSubmitHandler(svc) + + input := SubmitReportInput{ + TargetURI: "at://did:plc:author123/social.coves.post/abc123", + Reason: "invalid_reason", + } + body, _ := json.Marshal(input) + + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.admin.submitReport", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req = setTestUserDID(req, "did:plc:testuser123") + + w := httptest.NewRecorder() + handler.HandleSubmit(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected status %d, got %d", http.StatusBadRequest, w.Code) + } + + if !strings.Contains(w.Body.String(), "InvalidReason") { + t.Errorf("expected InvalidReason error, got %s", w.Body.String()) + } +} + +func TestHandleSubmit_InvalidTarget(t *testing.T) { + svc := &mockService{ + submitReportFunc: func(ctx context.Context, req adminreports.SubmitReportRequest) (*adminreports.SubmitReportResult, error) { + return nil, adminreports.ErrInvalidTarget + }, + } + handler := NewSubmitHandler(svc) + + input := SubmitReportInput{ + TargetURI: "https://example.com", + Reason: "spam", + } + body, _ := json.Marshal(input) + + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.admin.submitReport", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req = setTestUserDID(req, "did:plc:testuser123") + + w := httptest.NewRecorder() + handler.HandleSubmit(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected status %d, got %d", http.StatusBadRequest, w.Code) + } + + if !strings.Contains(w.Body.String(), "InvalidTarget") { + t.Errorf("expected InvalidTarget error, got %s", w.Body.String()) + } +} + +func TestHandleSubmit_ExplanationTooLong(t *testing.T) { + svc := &mockService{ + submitReportFunc: func(ctx context.Context, req adminreports.SubmitReportRequest) (*adminreports.SubmitReportResult, error) { + return nil, adminreports.ErrExplanationTooLong + }, + } + handler := NewSubmitHandler(svc) + + input := SubmitReportInput{ + TargetURI: "at://did:plc:author123/social.coves.post/abc123", + Reason: "spam", + Explanation: strings.Repeat("a", 1001), + } + body, _ := json.Marshal(input) + + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.admin.submitReport", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req = setTestUserDID(req, "did:plc:testuser123") + + w := httptest.NewRecorder() + handler.HandleSubmit(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected status %d, got %d", http.StatusBadRequest, w.Code) + } + + if !strings.Contains(w.Body.String(), "ExplanationTooLong") { + t.Errorf("expected ExplanationTooLong error, got %s", w.Body.String()) + } +} + +func TestHandleSubmit_InvalidStatus(t *testing.T) { + svc := &mockService{ + submitReportFunc: func(ctx context.Context, req adminreports.SubmitReportRequest) (*adminreports.SubmitReportResult, error) { + return nil, adminreports.ErrInvalidStatus + }, + } + handler := NewSubmitHandler(svc) + + input := SubmitReportInput{ + TargetURI: "at://did:plc:author123/social.coves.post/abc123", + Reason: "spam", + } + body, _ := json.Marshal(input) + + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.admin.submitReport", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req = setTestUserDID(req, "did:plc:testuser123") + + w := httptest.NewRecorder() + handler.HandleSubmit(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected status %d, got %d", http.StatusBadRequest, w.Code) + } + + if !strings.Contains(w.Body.String(), "InvalidStatus") { + t.Errorf("expected InvalidStatus error, got %s", w.Body.String()) + } +} + +func TestHandleSubmit_InvalidTargetType(t *testing.T) { + svc := &mockService{ + submitReportFunc: func(ctx context.Context, req adminreports.SubmitReportRequest) (*adminreports.SubmitReportResult, error) { + return nil, adminreports.ErrInvalidTargetType + }, + } + handler := NewSubmitHandler(svc) + + input := SubmitReportInput{ + TargetURI: "at://did:plc:author123/social.coves.post/abc123", + Reason: "spam", + } + body, _ := json.Marshal(input) + + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.admin.submitReport", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req = setTestUserDID(req, "did:plc:testuser123") + + w := httptest.NewRecorder() + handler.HandleSubmit(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected status %d, got %d", http.StatusBadRequest, w.Code) + } + + if !strings.Contains(w.Body.String(), "InvalidTargetType") { + t.Errorf("expected InvalidTargetType error, got %s", w.Body.String()) + } +} + +func TestHandleSubmit_NotFound(t *testing.T) { + svc := &mockService{ + submitReportFunc: func(ctx context.Context, req adminreports.SubmitReportRequest) (*adminreports.SubmitReportResult, error) { + return nil, adminreports.ErrReportNotFound + }, + } + handler := NewSubmitHandler(svc) + + input := SubmitReportInput{ + TargetURI: "at://did:plc:author123/social.coves.post/abc123", + Reason: "spam", + } + body, _ := json.Marshal(input) + + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.admin.submitReport", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req = setTestUserDID(req, "did:plc:testuser123") + + w := httptest.NewRecorder() + handler.HandleSubmit(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("expected status %d, got %d", http.StatusNotFound, w.Code) + } + + if !strings.Contains(w.Body.String(), "NotFound") { + t.Errorf("expected NotFound error, got %s", w.Body.String()) + } +} + +func TestHandleSubmit_InternalError(t *testing.T) { + svc := &mockService{ + submitReportFunc: func(ctx context.Context, req adminreports.SubmitReportRequest) (*adminreports.SubmitReportResult, error) { + return nil, errors.New("database connection failed") + }, + } + handler := NewSubmitHandler(svc) + + input := SubmitReportInput{ + TargetURI: "at://did:plc:author123/social.coves.post/abc123", + Reason: "spam", + } + body, _ := json.Marshal(input) + + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.admin.submitReport", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req = setTestUserDID(req, "did:plc:testuser123") + + w := httptest.NewRecorder() + handler.HandleSubmit(w, req) + + if w.Code != http.StatusInternalServerError { + t.Errorf("expected status %d, got %d", http.StatusInternalServerError, w.Code) + } + + if !strings.Contains(w.Body.String(), "InternalServerError") { + t.Errorf("expected InternalServerError error, got %s", w.Body.String()) + } + + // SECURITY: Verify that the actual error message is not leaked + if strings.Contains(w.Body.String(), "database") { + t.Error("internal error details should not be exposed to client") + } +} + +func TestHandleSubmit_EmptyExplanation(t *testing.T) { + svc := &mockService{ + submitReportFunc: func(ctx context.Context, req adminreports.SubmitReportRequest) (*adminreports.SubmitReportResult, error) { + if req.Explanation != "" { + t.Errorf("expected empty Explanation, got %q", req.Explanation) + } + return &adminreports.SubmitReportResult{ReportID: 1}, nil + }, + } + handler := NewSubmitHandler(svc) + + input := SubmitReportInput{ + TargetURI: "at://did:plc:author123/social.coves.post/abc123", + Reason: "spam", + Explanation: "", + } + body, _ := json.Marshal(input) + + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.admin.submitReport", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req = setTestUserDID(req, "did:plc:testuser123") + + w := httptest.NewRecorder() + handler.HandleSubmit(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected status %d, got %d", http.StatusOK, w.Code) + } +} + +func TestHandleSubmit_ContentTypeHeader(t *testing.T) { + handler := NewSubmitHandler(&mockService{}) + + input := SubmitReportInput{ + TargetURI: "at://did:plc:author123/social.coves.post/abc123", + Reason: "spam", + } + body, _ := json.Marshal(input) + + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.admin.submitReport", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req = setTestUserDID(req, "did:plc:testuser123") + + w := httptest.NewRecorder() + handler.HandleSubmit(w, req) + + contentType := w.Header().Get("Content-Type") + if contentType != "application/json" { + t.Errorf("expected Content-Type %q, got %q", "application/json", contentType) + } +} + +func TestWriteError(t *testing.T) { + w := httptest.NewRecorder() + writeError(w, http.StatusBadRequest, "TestError", "Test message") + + if w.Code != http.StatusBadRequest { + t.Errorf("expected status %d, got %d", http.StatusBadRequest, w.Code) + } + + var resp errorResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal error response: %v", err) + } + + if resp.Error != "TestError" { + t.Errorf("expected error %q, got %q", "TestError", resp.Error) + } + if resp.Message != "Test message" { + t.Errorf("expected message %q, got %q", "Test message", resp.Message) + } +} + +func TestHandleServiceError_AllValidationErrors(t *testing.T) { + tests := []struct { + name string + err error + expectedStatus int + expectedError string + }{ + { + name: "ErrInvalidReason", + err: adminreports.ErrInvalidReason, + expectedStatus: http.StatusBadRequest, + expectedError: "InvalidReason", + }, + { + name: "ErrInvalidStatus", + err: adminreports.ErrInvalidStatus, + expectedStatus: http.StatusBadRequest, + expectedError: "InvalidStatus", + }, + { + name: "ErrInvalidTarget", + err: adminreports.ErrInvalidTarget, + expectedStatus: http.StatusBadRequest, + expectedError: "InvalidTarget", + }, + { + name: "ErrExplanationTooLong", + err: adminreports.ErrExplanationTooLong, + expectedStatus: http.StatusBadRequest, + expectedError: "ExplanationTooLong", + }, + { + name: "ErrReporterRequired", + err: adminreports.ErrReporterRequired, + expectedStatus: http.StatusBadRequest, + expectedError: "ReporterRequired", + }, + { + name: "ErrInvalidTargetType", + err: adminreports.ErrInvalidTargetType, + expectedStatus: http.StatusBadRequest, + expectedError: "InvalidTargetType", + }, + { + name: "ErrReportNotFound", + err: adminreports.ErrReportNotFound, + expectedStatus: http.StatusNotFound, + expectedError: "NotFound", + }, + { + name: "internal error", + err: errors.New("some internal error"), + expectedStatus: http.StatusInternalServerError, + expectedError: "InternalServerError", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + w := httptest.NewRecorder() + handleServiceError(w, tt.err) + + if w.Code != tt.expectedStatus { + t.Errorf("expected status %d, got %d", tt.expectedStatus, w.Code) + } + + var resp errorResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal error response: %v", err) + } + + if resp.Error != tt.expectedError { + t.Errorf("expected error %q, got %q", tt.expectedError, resp.Error) + } + }) + } +} + +// setTestUserDID sets the user DID in the context for testing +func setTestUserDID(req *http.Request, userDID string) *http.Request { + ctx := middleware.SetTestUserDID(req.Context(), userDID) + return req.WithContext(ctx) +} diff --git a/internal/api/routes/adminreport.go b/internal/api/routes/adminreport.go new file mode 100644 index 0000000..730b9dc --- /dev/null +++ b/internal/api/routes/adminreport.go @@ -0,0 +1,32 @@ +package routes + +import ( + "Coves/internal/api/handlers/adminreport" + "Coves/internal/api/middleware" + "Coves/internal/core/adminreports" + "time" + + "github.com/go-chi/chi/v5" +) + +// RegisterAdminReportRoutes registers admin report XRPC endpoints on the router +// Implements social.coves.admin.* lexicon endpoints for content reporting +// All endpoints require authentication and are rate limited +func RegisterAdminReportRoutes(r chi.Router, service adminreports.Service, authMiddleware *middleware.OAuthAuthMiddleware) { + // Initialize handlers + submitHandler := adminreport.NewSubmitHandler(service) + + // Create rate limiter for report submission + // Allow 10 reports per minute per user to prevent abuse + // This is intentionally restrictive since report submission is a sensitive operation + reportRateLimiter := middleware.NewRateLimiter(10, time.Minute) + + // Procedure endpoints (POST) - require authentication and rate limiting + // social.coves.admin.submitReport - submit a report for admin review + r.With( + reportRateLimiter.Middleware, + authMiddleware.RequireAuth, + ).Post( + "/xrpc/social.coves.admin.submitReport", + submitHandler.HandleSubmit) +} diff --git a/internal/atproto/jetstream/comment_consumer.go b/internal/atproto/jetstream/comment_consumer.go index a0d423e..72f4819 100644 --- a/internal/atproto/jetstream/comment_consumer.go +++ b/internal/atproto/jetstream/comment_consumer.go @@ -100,7 +100,10 @@ func (c *CommentEventConsumer) createComment(ctx context.Context, repoDID string } // Serialize optional JSON fields - facetsJSON, embedJSON, labelsJSON := serializeOptionalFields(commentRecord) + facetsJSON, embedJSON, labelsJSON, err := serializeOptionalFields(commentRecord) + if err != nil { + return fmt.Errorf("failed to serialize optional fields: %w", err) + } // Build comment entity comment := &comments.Comment{ @@ -177,7 +180,10 @@ func (c *CommentEventConsumer) updateComment(ctx context.Context, repoDID string } // Serialize optional JSON fields - facetsJSON, embedJSON, labelsJSON := serializeOptionalFields(commentRecord) + facetsJSON, embedJSON, labelsJSON, err := serializeOptionalFields(commentRecord) + if err != nil { + return fmt.Errorf("failed to serialize optional fields: %w", err) + } // Build comment update entity (preserves vote counts and created_at) comment := &comments.Comment{ @@ -241,10 +247,12 @@ func (c *CommentEventConsumer) indexCommentAndUpdateCounts(ctx context.Context, // We must distinguish: idempotent replay (skip) vs resurrection (update + restore counts) var existingID int64 var existingDeletedAt *time.Time - checkQuery := `SELECT id, deleted_at FROM comments WHERE uri = $1` - checkErr := tx.QueryRowContext(ctx, checkQuery, comment.URI).Scan(&existingID, &existingDeletedAt) + var existingParentURI, existingRootURI string + checkQuery := `SELECT id, deleted_at, parent_uri, root_uri FROM comments WHERE uri = $1` + checkErr := tx.QueryRowContext(ctx, checkQuery, comment.URI).Scan(&existingID, &existingDeletedAt, &existingParentURI, &existingRootURI) var commentID int64 + var isResurrectionWithSameParent bool // Track if we should skip parent count increment if checkErr == nil { // Comment exists @@ -264,6 +272,11 @@ func (c *CommentEventConsumer) indexCommentAndUpdateCounts(ctx context.Context, log.Printf("Resurrecting previously deleted comment: %s", comment.URI) commentID = existingID + // Check if parent is the same - if so, we should NOT increment parent counts + // because deleteComment() no longer decrements counts (deleted = placeholder) + // If parent is different, we need to increment the NEW parent's count + isResurrectionWithSameParent = (existingParentURI == comment.ParentURI && existingRootURI == comment.RootURI) + resurrectQuery := ` UPDATE comments SET @@ -355,51 +368,118 @@ func (c *CommentEventConsumer) indexCommentAndUpdateCounts(ctx context.Context, // 1.5. Reconcile reply_count for this newly inserted comment // In case any replies arrived out-of-order before this parent was indexed + // NOTE: Counts include deleted comments since they're shown as "[deleted]" placeholders + // + // IMPORTANT: This reconciliation logic and the increment logic below (in parent count updates) + // must stay in sync. Both use the same counting semantics: + // - Count ALL comments (including deleted) since deleted comments appear as "[deleted]" placeholders + // - This ensures reply_count matches the actual visible thread structure + // If you modify one, you must review and potentially modify the other. reconcileQuery := ` UPDATE comments SET reply_count = ( SELECT COUNT(*) FROM comments c - WHERE c.parent_uri = $1 AND c.deleted_at IS NULL + WHERE c.parent_uri = $1 ) WHERE id = $2 ` _, reconcileErr := tx.ExecContext(ctx, reconcileQuery, comment.URI, commentID) if reconcileErr != nil { - log.Printf("Warning: Failed to reconcile reply_count for %s: %v", comment.URI, reconcileErr) - // Continue anyway - this is a best-effort reconciliation + // Reconciliation failure is a critical error - it means reply_count will be incorrect + // This could cause data inconsistency where the displayed count doesn't match reality + // Roll back the transaction to maintain consistency + return fmt.Errorf("failed to reconcile reply_count for %s: %w", comment.URI, reconcileErr) } // 2. Update parent counts atomically // Parent could be a post (increment comment_count) or a comment (increment reply_count) // Parse collection from parent URI to determine target table // - // NOTE: Post comment_count reconciliation IS implemented in post_consumer.go:210-226 + // SKIP if this is a resurrection with the same parent: + // Since deleteComment() no longer decrements counts (deleted comments shown as "[deleted]" placeholders), + // resurrecting a comment with the same parent should NOT increment the count again. + // However, if the parent CHANGED (user recreated comment on different post/thread), we DO increment. + // + // NOTE: Post comment_count reconciliation IS implemented in PostEventConsumer.createPostAndUpdateCounts() // When a comment arrives before its parent post, the post update below returns 0 rows // and we log a warning. Later, when the post is indexed, the post consumer reconciles // comment_count by counting all pre-existing comments. This ensures accurate counts // despite out-of-order Jetstream event delivery. // // Test coverage: TestPostConsumer_CommentCountReconciliation in post_consumer_test.go + if isResurrectionWithSameParent { + log.Printf("Resurrection with same parent - skipping parent count increment for: %s", comment.URI) + if err := tx.Commit(); err != nil { + return fmt.Errorf("failed to commit transaction: %w", err) + } + return nil + } + collection := utils.ExtractCollectionFromURI(comment.ParentURI) - var updateQuery string switch collection { case "social.coves.community.post": - // Comment on post - update posts.comment_count - updateQuery = ` + // Top-level comment on post - increment posts.comment_count + // NOTE: No deleted_at filter - we increment even for deleted parents to match reconciliation behavior + updateQuery := ` UPDATE posts SET comment_count = comment_count + 1 - WHERE uri = $1 AND deleted_at IS NULL + WHERE uri = $1 ` + result, err := tx.ExecContext(ctx, updateQuery, comment.ParentURI) + if err != nil { + return fmt.Errorf("failed to update post comment_count: %w", err) + } + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("failed to check update result: %w", err) + } + if rowsAffected == 0 { + log.Printf("Warning: Post not found: %s (comment indexed anyway)", comment.ParentURI) + } case "social.coves.community.comment": - // Reply to comment - update comments.reply_count - updateQuery = ` + // Nested reply to comment - update BOTH: + // 1. Parent comment's reply_count (for thread structure) + // 2. Root post's comment_count (for total thread count display) + // NOTE: No deleted_at filter - we increment even for deleted parents to match reconciliation behavior + + // Update parent comment's reply_count + replyQuery := ` UPDATE comments SET reply_count = reply_count + 1 - WHERE uri = $1 AND deleted_at IS NULL + WHERE uri = $1 ` + result, err := tx.ExecContext(ctx, replyQuery, comment.ParentURI) + if err != nil { + return fmt.Errorf("failed to update parent reply_count: %w", err) + } + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("failed to check reply update result: %w", err) + } + if rowsAffected == 0 { + log.Printf("Warning: Parent comment not found: %s (comment indexed anyway)", comment.ParentURI) + } + + // Also increment root post's comment_count for total thread count + postQuery := ` + UPDATE posts + SET comment_count = comment_count + 1 + WHERE uri = $1 + ` + result, err = tx.ExecContext(ctx, postQuery, comment.RootURI) + if err != nil { + return fmt.Errorf("failed to update root post comment_count: %w", err) + } + rowsAffected, err = result.RowsAffected() + if err != nil { + return fmt.Errorf("failed to check post update result: %w", err) + } + if rowsAffected == 0 { + log.Printf("Warning: Root post not found: %s (comment indexed anyway)", comment.RootURI) + } default: // Unknown or unsupported parent collection @@ -411,21 +491,6 @@ func (c *CommentEventConsumer) indexCommentAndUpdateCounts(ctx context.Context, return nil } - result, err := tx.ExecContext(ctx, updateQuery, comment.ParentURI) - if err != nil { - return fmt.Errorf("failed to update parent count: %w", err) - } - - rowsAffected, err := result.RowsAffected() - if err != nil { - return fmt.Errorf("failed to check update result: %w", err) - } - - // If parent not found, that's OK (parent might not be indexed yet) - if rowsAffected == 0 { - log.Printf("Warning: Parent not found or deleted: %s (comment indexed anyway)", comment.ParentURI) - } - // Commit transaction if err := tx.Commit(); err != nil { return fmt.Errorf("failed to commit transaction: %w", err) @@ -462,62 +527,18 @@ func (c *CommentEventConsumer) deleteCommentAndUpdateCounts(ctx context.Context, return fmt.Errorf("failed to delete comment: %w", err) } - // Idempotent: If no rows affected, comment already deleted + // Idempotent: If no rows affected, comment already deleted - return early if rowsAffected == 0 { log.Printf("Comment already deleted: %s (idempotent)", comment.URI) - if commitErr := tx.Commit(); commitErr != nil { - return fmt.Errorf("failed to commit transaction: %w", commitErr) - } - return nil - } - - // 2. Decrement parent counts atomically - // Parent could be a post or comment - parse collection to determine target table - collection := utils.ExtractCollectionFromURI(comment.ParentURI) - - var updateQuery string - var result sql.Result - switch collection { - case "social.coves.community.post": - // Comment on post - decrement posts.comment_count - updateQuery = ` - UPDATE posts - SET comment_count = GREATEST(0, comment_count - 1) - WHERE uri = $1 AND deleted_at IS NULL - ` - - case "social.coves.community.comment": - // Reply to comment - decrement comments.reply_count - updateQuery = ` - UPDATE comments - SET reply_count = GREATEST(0, reply_count - 1) - WHERE uri = $1 AND deleted_at IS NULL - ` - - default: - // Unknown or unsupported parent collection - // Comment is still deleted, we just don't update parent counts - log.Printf("Comment parent has unsupported collection: %s (comment deleted, parent count not updated)", collection) - if commitErr := tx.Commit(); commitErr != nil { - return fmt.Errorf("failed to commit transaction: %w", commitErr) + if err := tx.Commit(); err != nil { + return fmt.Errorf("failed to commit transaction: %w", err) } return nil } - result, err = tx.ExecContext(ctx, updateQuery, comment.ParentURI) - if err != nil { - return fmt.Errorf("failed to update parent count: %w", err) - } - - rowsAffected, err = result.RowsAffected() - if err != nil { - return fmt.Errorf("failed to check update result: %w", err) - } - - // If parent not found, that's OK (parent might be deleted) - if rowsAffected == 0 { - log.Printf("Warning: Parent not found or deleted: %s (comment deleted anyway)", comment.ParentURI) - } + // NOTE: We intentionally do NOT decrement parent counts (comment_count/reply_count) + // Deleted comments are shown as "[deleted]" placeholders to preserve thread structure, + // so they should still count toward the displayed total. // Commit transaction if err := tx.Commit(); err != nil { @@ -656,30 +677,37 @@ func parseCommentRecord(record map[string]interface{}) (*CommentRecordFromJetstr // serializeOptionalFields serializes facets, embed, and labels from a comment record to JSON strings // Returns nil pointers for empty/nil fields (DRY helper to avoid duplication) -func serializeOptionalFields(commentRecord *CommentRecordFromJetstream) (facetsJSON, embedJSON, labelsJSON *string) { +// Returns an error if any non-empty field fails to serialize (prevents silent data loss) +func serializeOptionalFields(commentRecord *CommentRecordFromJetstream) (facetsJSON, embedJSON, labelsJSON *string, err error) { // Serialize facets if present if len(commentRecord.Facets) > 0 { - if facetsBytes, err := json.Marshal(commentRecord.Facets); err == nil { - facetsStr := string(facetsBytes) - facetsJSON = &facetsStr + facetsBytes, marshalErr := json.Marshal(commentRecord.Facets) + if marshalErr != nil { + return nil, nil, nil, fmt.Errorf("failed to serialize facets: %w", marshalErr) } + facetsStr := string(facetsBytes) + facetsJSON = &facetsStr } // Serialize embed if present if len(commentRecord.Embed) > 0 { - if embedBytes, err := json.Marshal(commentRecord.Embed); err == nil { - embedStr := string(embedBytes) - embedJSON = &embedStr + embedBytes, marshalErr := json.Marshal(commentRecord.Embed) + if marshalErr != nil { + return nil, nil, nil, fmt.Errorf("failed to serialize embed: %w", marshalErr) } + embedStr := string(embedBytes) + embedJSON = &embedStr } // Serialize labels if present if commentRecord.Labels != nil { - if labelsBytes, err := json.Marshal(commentRecord.Labels); err == nil { - labelsStr := string(labelsBytes) - labelsJSON = &labelsStr + labelsBytes, marshalErr := json.Marshal(commentRecord.Labels) + if marshalErr != nil { + return nil, nil, nil, fmt.Errorf("failed to serialize labels: %w", marshalErr) } + labelsStr := string(labelsBytes) + labelsJSON = &labelsStr } - return facetsJSON, embedJSON, labelsJSON + return facetsJSON, embedJSON, labelsJSON, nil } diff --git a/internal/atproto/jetstream/post_consumer.go b/internal/atproto/jetstream/post_consumer.go index 7a65e46..7632205 100644 --- a/internal/atproto/jetstream/post_consumer.go +++ b/internal/atproto/jetstream/post_consumer.go @@ -7,9 +7,9 @@ import ( "context" "database/sql" "encoding/json" + "errors" "fmt" "log" - "strings" "time" ) @@ -111,28 +111,32 @@ func (c *PostEventConsumer) createPost(ctx context.Context, repoDID string, comm } // Serialize JSON fields (facets, embed, labels) + // Return error if any non-empty field fails to serialize (prevents silent data loss) if postRecord.Facets != nil { facetsJSON, marshalErr := json.Marshal(postRecord.Facets) - if marshalErr == nil { - facetsStr := string(facetsJSON) - post.ContentFacets = &facetsStr + if marshalErr != nil { + return fmt.Errorf("failed to serialize facets: %w", marshalErr) } + facetsStr := string(facetsJSON) + post.ContentFacets = &facetsStr } if postRecord.Embed != nil { embedJSON, marshalErr := json.Marshal(postRecord.Embed) - if marshalErr == nil { - embedStr := string(embedJSON) - post.Embed = &embedStr + if marshalErr != nil { + return fmt.Errorf("failed to serialize embed: %w", marshalErr) } + embedStr := string(embedJSON) + post.Embed = &embedStr } if postRecord.Labels != nil { labelsJSON, marshalErr := json.Marshal(postRecord.Labels) - if marshalErr == nil { - labelsStr := string(labelsJSON) - post.ContentLabels = &labelsStr + if marshalErr != nil { + return fmt.Errorf("failed to serialize labels: %w", marshalErr) } + labelsStr := string(labelsJSON) + post.ContentLabels = &labelsStr } // Atomically: Index post + Reconcile comment count for out-of-order arrivals @@ -230,19 +234,30 @@ func (c *PostEventConsumer) indexPostAndReconcileCounts(ctx context.Context, pos // 2. Reconcile comment_count for this newly inserted post // In case any comments arrived out-of-order before this post was indexed // This is the CRITICAL FIX for the race condition identified in the PR review + // NOTE: Uses root_uri to count ALL comments in thread (including nested replies) + // NOTE: Counts include deleted comments since they're shown as "[deleted]" placeholders + // + // IMPORTANT: This reconciliation logic and the increment logic in CommentEventConsumer + // must stay in sync. Both use the same counting semantics: + // - Count ALL comments (including deleted) since deleted comments appear as "[deleted]" placeholders + // - This ensures comment_count matches the actual visible thread structure + // If you modify one, you must review and potentially modify the other. + // See: comment_consumer.go indexCommentAndUpdateCounts() reconcileQuery := ` UPDATE posts SET comment_count = ( SELECT COUNT(*) FROM comments c - WHERE c.parent_uri = $1 AND c.deleted_at IS NULL + WHERE c.root_uri = $1 ) WHERE id = $2 ` _, reconcileErr := tx.ExecContext(ctx, reconcileQuery, post.URI, postID) if reconcileErr != nil { - log.Printf("Warning: Failed to reconcile comment_count for %s: %v", post.URI, reconcileErr) - // Continue anyway - this is a best-effort reconciliation + // Reconciliation failure is a critical error - it means comment_count will be incorrect + // This could cause data inconsistency where the displayed count doesn't match reality + // Roll back the transaction to maintain consistency + return fmt.Errorf("failed to reconcile comment_count for %s: %w", post.URI, reconcileErr) } // Commit transaction @@ -294,9 +309,8 @@ func (c *PostEventConsumer) validatePostEvent(ctx context.Context, repoDID strin // If author isn't indexed yet, we must reject the post _, err = c.userService.GetUserByDID(ctx, post.Author) if err != nil { - // Check if it's a "not found" error using string matching - // (users package doesn't export IsNotFound) - if err.Error() == "user not found" || strings.Contains(err.Error(), "not found") { + // Use proper error type checking with errors.Is() + if errors.Is(err, users.ErrUserNotFound) { // Reject - author must be indexed before posts // This maintains referential integrity and prevents orphaned posts return fmt.Errorf("author not found: %s - cannot index post before author", post.Author) diff --git a/internal/core/adminreports/errors.go b/internal/core/adminreports/errors.go new file mode 100644 index 0000000..164348d --- /dev/null +++ b/internal/core/adminreports/errors.go @@ -0,0 +1,41 @@ +package adminreports + +import "errors" + +var ( + // ErrInvalidReason indicates the report reason is not a valid category + ErrInvalidReason = errors.New("invalid report reason: must be one of csam, doxing, harassment, spam, illegal, other") + + // ErrInvalidStatus indicates the report status is not a valid value + ErrInvalidStatus = errors.New("invalid report status: must be one of open, reviewing, resolved, dismissed") + + // ErrInvalidTarget indicates the target URI is malformed or invalid + ErrInvalidTarget = errors.New("invalid target URI: must be a valid AT Protocol URI starting with at://") + + // ErrExplanationTooLong indicates the explanation exceeds the maximum length + ErrExplanationTooLong = errors.New("explanation exceeds maximum length of 1000 characters") + + // ErrReporterRequired indicates the reporter DID was not provided + ErrReporterRequired = errors.New("reporter DID is required") + + // ErrReportNotFound indicates the requested report does not exist + ErrReportNotFound = errors.New("report not found") + + // ErrInvalidTargetType indicates the target type is not a valid value + ErrInvalidTargetType = errors.New("invalid target type: must be one of post, comment") +) + +// IsValidationError checks if an error is a validation error +func IsValidationError(err error) bool { + return errors.Is(err, ErrInvalidReason) || + errors.Is(err, ErrInvalidStatus) || + errors.Is(err, ErrInvalidTarget) || + errors.Is(err, ErrExplanationTooLong) || + errors.Is(err, ErrReporterRequired) || + errors.Is(err, ErrInvalidTargetType) +} + +// IsNotFound checks if an error is a "not found" error +func IsNotFound(err error) bool { + return errors.Is(err, ErrReportNotFound) +} diff --git a/internal/core/adminreports/interfaces.go b/internal/core/adminreports/interfaces.go new file mode 100644 index 0000000..d69be8e --- /dev/null +++ b/internal/core/adminreports/interfaces.go @@ -0,0 +1,29 @@ +package adminreports + +import "context" + +// Repository defines the data access layer for admin reports +type Repository interface { + // Create stores a new report in the database + // Returns the report with ID populated after successful creation + Create(ctx context.Context, report *Report) error + + // ListByStatus returns reports filtered by status with pagination + ListByStatus(ctx context.Context, status string, limit, offset int) ([]*Report, error) + + // UpdateStatus updates a report's status and resolution details + UpdateStatus(ctx context.Context, id int64, status, resolvedBy, notes string) error +} + +// SubmitReportResult contains the result of submitting a report +type SubmitReportResult struct { + // ReportID is the ID of the created report + ReportID int64 +} + +// Service defines the business logic layer for admin reports +type Service interface { + // SubmitReport validates and creates a new report + // Returns the report ID on success + SubmitReport(ctx context.Context, req SubmitReportRequest) (*SubmitReportResult, error) +} diff --git a/internal/core/adminreports/report.go b/internal/core/adminreports/report.go new file mode 100644 index 0000000..83ad5e0 --- /dev/null +++ b/internal/core/adminreports/report.go @@ -0,0 +1,204 @@ +package adminreports + +import ( + "log" + "regexp" + "strings" + "time" + "unicode/utf8" +) + +// Report represents an admin report in the AppView database +// Reports are created by users to flag serious content for admin review +type Report struct { + ID int64 `json:"id" db:"id"` + ReporterDID string `json:"reporterDid" db:"reporter_did"` + TargetURI string `json:"targetUri" db:"target_uri"` + TargetType TargetType `json:"targetType" db:"target_type"` + Reason Reason `json:"reason" db:"reason"` + Explanation string `json:"explanation,omitempty" db:"explanation"` + Status Status `json:"status" db:"status"` + ResolvedBy *string `json:"resolvedBy,omitempty" db:"resolved_by"` + ResolutionNotes *string `json:"resolutionNotes,omitempty" db:"resolution_notes"` + CreatedAt time.Time `json:"createdAt" db:"created_at"` + ResolvedAt *time.Time `json:"resolvedAt,omitempty" db:"resolved_at"` +} + +// Reason represents the category of an admin report +type Reason string + +// Valid reason values for admin reports +const ( + ReasonCSAM Reason = "csam" + ReasonDoxing Reason = "doxing" + ReasonHarassment Reason = "harassment" + ReasonSpam Reason = "spam" + ReasonIllegal Reason = "illegal" + ReasonOther Reason = "other" +) + +// Status represents the processing status of an admin report +type Status string + +// Valid status values for admin reports +const ( + StatusOpen Status = "open" + StatusReviewing Status = "reviewing" + StatusResolved Status = "resolved" + StatusDismissed Status = "dismissed" +) + +// TargetType represents the type of content being reported +type TargetType string + +// Valid target types for admin reports +const ( + TargetTypePost TargetType = "post" + TargetTypeComment TargetType = "comment" +) + +// ValidReasons returns all valid reason values +func ValidReasons() []Reason { + return []Reason{ReasonCSAM, ReasonDoxing, ReasonHarassment, ReasonSpam, ReasonIllegal, ReasonOther} +} + +// ValidStatuses returns all valid status values +func ValidStatuses() []Status { + return []Status{StatusOpen, StatusReviewing, StatusResolved, StatusDismissed} +} + +// ValidTargetTypes returns all valid target type values +func ValidTargetTypes() []TargetType { + return []TargetType{TargetTypePost, TargetTypeComment} +} + +// IsValidReason checks if a reason value is valid +func IsValidReason(reason string) bool { + for _, r := range ValidReasons() { + if string(r) == reason { + return true + } + } + return false +} + +// 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 +} + +// IsValidTargetType checks if a target type value is valid +func IsValidTargetType(targetType string) bool { + for _, t := range ValidTargetTypes() { + if string(t) == targetType { + return true + } + } + return false +} + +// MaxExplanationLength is the maximum number of characters allowed in an explanation +const MaxExplanationLength = 1000 + +// SubmitReportRequest contains the data needed to submit a new report +type SubmitReportRequest struct { + // ReporterDID is the DID of the user submitting the report + ReporterDID string + + // TargetURI is the AT Protocol URI of the content being reported + TargetURI string + + // Reason is the category of the report + Reason string + + // Explanation is an optional description of the issue + Explanation string +} + +// atURIPattern validates AT Protocol URIs with proper structure: +// at://did:plc:xxx/collection/rkey or at://did:web:xxx/collection/rkey +// Note: This validation focuses on structure rather than strict DID format validation, +// which is the responsibility of the PDS. The pattern allows alphanumeric DID identifiers. +var atURIPattern = regexp.MustCompile(`^at://did:(plc:[a-zA-Z0-9]+|web:[a-zA-Z0-9.-]+)/[a-zA-Z0-9.]+/[a-zA-Z0-9_-]+$`) + +// Validate validates the SubmitReportRequest and returns an error if invalid +func (r *SubmitReportRequest) Validate() error { + // Validate reporter DID + if r.ReporterDID == "" { + return ErrReporterRequired + } + + // Validate reason is one of the allowed values + if !IsValidReason(r.Reason) { + return ErrInvalidReason + } + + // Validate target URI is a proper AT Protocol URI + if !isValidATURI(r.TargetURI) { + return ErrInvalidTarget + } + + // Validate explanation length (max 1000 characters, using proper character counting) + if utf8.RuneCountInString(r.Explanation) > MaxExplanationLength { + return ErrExplanationTooLong + } + + return nil +} + +// isValidATURI validates that the URI is a proper AT Protocol URI +// AT Protocol URIs have the format: at://did:plc:xxx/collection/rkey +// or at://did:web:xxx/collection/rkey +func isValidATURI(uri string) bool { + // Check basic prefix + if !strings.HasPrefix(uri, "at://") { + return false + } + + // Validate the full URI pattern + return atURIPattern.MatchString(uri) +} + +// determineTargetType determines whether the target is a post or comment based on the URI +// AT Protocol URIs have the format: at://did:plc:xxx/collection/rkey +// For Coves, the collection will contain "post" or "comment" +func determineTargetType(uri string) TargetType { + // Check if the URI contains common post or comment collection patterns + lowerURI := strings.ToLower(uri) + + if strings.Contains(lowerURI, "comment") { + return TargetTypeComment + } + + // Log when defaulting to post for unknown target types + if !strings.Contains(lowerURI, "post") { + log.Printf("[ADMIN_REPORT] Unknown target type in URI, defaulting to post: %s", uri) + } + + return TargetTypePost +} + +// NewReport creates a new Report from a validated SubmitReportRequest +// This constructor ensures that reports are created with proper defaults and validation +// The request must be validated before calling this function +func NewReport(req SubmitReportRequest) (*Report, error) { + // Validate the request first + if err := req.Validate(); err != nil { + return nil, err + } + + return &Report{ + ReporterDID: req.ReporterDID, + TargetURI: req.TargetURI, + TargetType: determineTargetType(req.TargetURI), + Reason: Reason(req.Reason), + Explanation: req.Explanation, + Status: StatusOpen, + CreatedAt: time.Now().UTC(), + }, nil +} diff --git a/internal/core/adminreports/service.go b/internal/core/adminreports/service.go new file mode 100644 index 0000000..11ec683 --- /dev/null +++ b/internal/core/adminreports/service.go @@ -0,0 +1,36 @@ +package adminreports + +import ( + "context" +) + +// service implements the Service interface for admin reports +type service struct { + repo Repository +} + +// NewService creates a new admin reports service +func NewService(repo Repository) Service { + return &service{ + repo: repo, + } +} + +// SubmitReport validates the report request and creates a new report +// Returns the report ID on success +func (s *service) SubmitReport(ctx context.Context, req SubmitReportRequest) (*SubmitReportResult, error) { + // Use the constructor which handles validation and target type determination + report, err := NewReport(req) + if err != nil { + return nil, err + } + + // Create the report in the database + if err := s.repo.Create(ctx, report); err != nil { + return nil, err + } + + return &SubmitReportResult{ + ReportID: report.ID, + }, nil +} diff --git a/internal/core/adminreports/service_test.go b/internal/core/adminreports/service_test.go new file mode 100644 index 0000000..40f3483 --- /dev/null +++ b/internal/core/adminreports/service_test.go @@ -0,0 +1,622 @@ +package adminreports + +import ( + "context" + "errors" + "strings" + "testing" +) + +// mockRepository implements Repository for testing +type mockRepository struct { + createFunc func(ctx context.Context, report *Report) error + listByStatusFunc func(ctx context.Context, status string, limit, offset int) ([]*Report, error) + updateStatusFunc func(ctx context.Context, id int64, status, resolvedBy, notes string) error + createdReports []*Report +} + +func (m *mockRepository) Create(ctx context.Context, report *Report) error { + if m.createFunc != nil { + return m.createFunc(ctx, report) + } + // Default behavior: assign ID and store report + report.ID = int64(len(m.createdReports) + 1) + m.createdReports = append(m.createdReports, report) + return nil +} + +func (m *mockRepository) ListByStatus(ctx context.Context, status string, limit, offset int) ([]*Report, error) { + if m.listByStatusFunc != nil { + return m.listByStatusFunc(ctx, status, limit, offset) + } + return []*Report{}, nil +} + +func (m *mockRepository) UpdateStatus(ctx context.Context, id int64, status, resolvedBy, notes string) error { + if m.updateStatusFunc != nil { + return m.updateStatusFunc(ctx, id, status, resolvedBy, notes) + } + return nil +} + +func TestSubmitReport_Success(t *testing.T) { + repo := &mockRepository{} + svc := NewService(repo) + + req := SubmitReportRequest{ + ReporterDID: "did:plc:testuser123", + TargetURI: "at://did:plc:author123/social.coves.post/abc123", + Reason: "spam", + Explanation: "This is spam content", + } + + result, err := svc.SubmitReport(context.Background(), req) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + if result == nil { + t.Fatal("expected result, got nil") + } + + if result.ReportID != 1 { + t.Errorf("expected ReportID 1, got %d", result.ReportID) + } + + if len(repo.createdReports) != 1 { + t.Fatalf("expected 1 created report, got %d", len(repo.createdReports)) + } + + created := repo.createdReports[0] + if created.ReporterDID != req.ReporterDID { + t.Errorf("expected ReporterDID %q, got %q", req.ReporterDID, created.ReporterDID) + } + if created.TargetURI != req.TargetURI { + t.Errorf("expected TargetURI %q, got %q", req.TargetURI, created.TargetURI) + } + if created.Reason != Reason(req.Reason) { + t.Errorf("expected Reason %q, got %q", req.Reason, created.Reason) + } + if created.Explanation != req.Explanation { + t.Errorf("expected Explanation %q, got %q", req.Explanation, created.Explanation) + } + if created.Status != StatusOpen { + t.Errorf("expected Status %q, got %q", StatusOpen, created.Status) + } + if created.TargetType != TargetTypePost { + t.Errorf("expected TargetType %q, got %q", TargetTypePost, created.TargetType) + } +} + +func TestSubmitReport_CommentTargetType(t *testing.T) { + repo := &mockRepository{} + svc := NewService(repo) + + req := SubmitReportRequest{ + ReporterDID: "did:plc:testuser123", + TargetURI: "at://did:plc:author123/social.coves.comment/xyz789", + Reason: "harassment", + Explanation: "Harassing comment", + } + + result, err := svc.SubmitReport(context.Background(), req) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + if result == nil { + t.Fatal("expected result, got nil") + } + + created := repo.createdReports[0] + if created.TargetType != TargetTypeComment { + t.Errorf("expected TargetType %q, got %q", TargetTypeComment, created.TargetType) + } +} + +func TestSubmitReport_ValidationErrors(t *testing.T) { + repo := &mockRepository{} + svc := NewService(repo) + + tests := []struct { + name string + req SubmitReportRequest + expectedErr error + }{ + { + name: "missing reporter DID", + req: SubmitReportRequest{ + ReporterDID: "", + TargetURI: "at://did:plc:author123/social.coves.post/abc123", + Reason: "spam", + }, + expectedErr: ErrReporterRequired, + }, + { + name: "invalid reason", + req: SubmitReportRequest{ + ReporterDID: "did:plc:testuser123", + TargetURI: "at://did:plc:author123/social.coves.post/abc123", + Reason: "invalid_reason", + }, + expectedErr: ErrInvalidReason, + }, + { + name: "missing target URI prefix", + req: SubmitReportRequest{ + ReporterDID: "did:plc:testuser123", + TargetURI: "https://example.com/post/123", + Reason: "spam", + }, + expectedErr: ErrInvalidTarget, + }, + { + name: "incomplete AT URI - only prefix", + req: SubmitReportRequest{ + ReporterDID: "did:plc:testuser123", + TargetURI: "at://", + Reason: "spam", + }, + expectedErr: ErrInvalidTarget, + }, + { + name: "malformed AT URI - missing collection", + req: SubmitReportRequest{ + ReporterDID: "did:plc:testuser123", + TargetURI: "at://did:plc:author123", + Reason: "spam", + }, + expectedErr: ErrInvalidTarget, + }, + { + name: "malformed AT URI - missing rkey", + req: SubmitReportRequest{ + ReporterDID: "did:plc:testuser123", + TargetURI: "at://did:plc:author123/social.coves.post", + Reason: "spam", + }, + expectedErr: ErrInvalidTarget, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := svc.SubmitReport(context.Background(), tt.req) + if !errors.Is(err, tt.expectedErr) { + t.Errorf("expected error %v, got %v", tt.expectedErr, err) + } + }) + } +} + +func TestSubmitReport_ExplanationTooLong(t *testing.T) { + repo := &mockRepository{} + svc := NewService(repo) + + // Create explanation longer than 1000 characters + longExplanation := strings.Repeat("a", MaxExplanationLength+1) + + req := SubmitReportRequest{ + ReporterDID: "did:plc:testuser123", + TargetURI: "at://did:plc:author123/social.coves.post/abc123", + Reason: "spam", + Explanation: longExplanation, + } + + _, err := svc.SubmitReport(context.Background(), req) + if !errors.Is(err, ErrExplanationTooLong) { + t.Errorf("expected ErrExplanationTooLong, got %v", err) + } +} + +func TestSubmitReport_ExplanationExactlyAtLimit(t *testing.T) { + repo := &mockRepository{} + svc := NewService(repo) + + // Create explanation at exactly 1000 characters + exactExplanation := strings.Repeat("a", MaxExplanationLength) + + req := SubmitReportRequest{ + ReporterDID: "did:plc:testuser123", + TargetURI: "at://did:plc:author123/social.coves.post/abc123", + Reason: "spam", + Explanation: exactExplanation, + } + + _, err := svc.SubmitReport(context.Background(), req) + if err != nil { + t.Fatalf("expected no error for explanation at limit, got: %v", err) + } +} + +func TestSubmitReport_ExplanationWithMultibyteCharacters(t *testing.T) { + repo := &mockRepository{} + svc := NewService(repo) + + // Create explanation with 1001 multibyte characters (should fail) + // Each emoji is 1 character but multiple bytes + multibyteExplanation := strings.Repeat("🔥", MaxExplanationLength+1) + + req := SubmitReportRequest{ + ReporterDID: "did:plc:testuser123", + TargetURI: "at://did:plc:author123/social.coves.post/abc123", + Reason: "spam", + Explanation: multibyteExplanation, + } + + _, err := svc.SubmitReport(context.Background(), req) + if !errors.Is(err, ErrExplanationTooLong) { + t.Errorf("expected ErrExplanationTooLong for multibyte characters exceeding limit, got %v", err) + } +} + +func TestSubmitReport_ExplanationWithMultibyteCharactersAtLimit(t *testing.T) { + repo := &mockRepository{} + svc := NewService(repo) + + // Create explanation with exactly 1000 multibyte characters (should pass) + multibyteExplanation := strings.Repeat("🔥", MaxExplanationLength) + + req := SubmitReportRequest{ + ReporterDID: "did:plc:testuser123", + TargetURI: "at://did:plc:author123/social.coves.post/abc123", + Reason: "spam", + Explanation: multibyteExplanation, + } + + _, err := svc.SubmitReport(context.Background(), req) + if err != nil { + t.Fatalf("expected no error for multibyte explanation at limit, got: %v", err) + } +} + +func TestSubmitReport_RepositoryError(t *testing.T) { + expectedErr := errors.New("database connection failed") + repo := &mockRepository{ + createFunc: func(ctx context.Context, report *Report) error { + return expectedErr + }, + } + svc := NewService(repo) + + req := SubmitReportRequest{ + ReporterDID: "did:plc:testuser123", + TargetURI: "at://did:plc:author123/social.coves.post/abc123", + Reason: "spam", + } + + _, err := svc.SubmitReport(context.Background(), req) + if !errors.Is(err, expectedErr) { + t.Errorf("expected repository error, got %v", err) + } +} + +func TestSubmitReport_AllValidReasons(t *testing.T) { + validReasons := []string{"csam", "doxing", "harassment", "spam", "illegal", "other"} + + for _, reason := range validReasons { + t.Run("reason_"+reason, func(t *testing.T) { + repo := &mockRepository{} + svc := NewService(repo) + + req := SubmitReportRequest{ + ReporterDID: "did:plc:testuser123", + TargetURI: "at://did:plc:author123/social.coves.post/abc123", + Reason: reason, + } + + result, err := svc.SubmitReport(context.Background(), req) + if err != nil { + t.Fatalf("expected no error for reason %q, got: %v", reason, err) + } + if result == nil { + t.Fatalf("expected result for reason %q, got nil", reason) + } + }) + } +} + +func TestSubmitReport_DidWebURI(t *testing.T) { + repo := &mockRepository{} + svc := NewService(repo) + + req := SubmitReportRequest{ + ReporterDID: "did:plc:testuser123", + TargetURI: "at://did:web:example.com/social.coves.post/abc123", + Reason: "spam", + } + + result, err := svc.SubmitReport(context.Background(), req) + if err != nil { + t.Fatalf("expected no error for did:web URI, got: %v", err) + } + if result == nil { + t.Fatal("expected result, got nil") + } +} + +func TestIsValidReason(t *testing.T) { + tests := []struct { + reason string + expected bool + }{ + {"csam", true}, + {"doxing", true}, + {"harassment", true}, + {"spam", true}, + {"illegal", true}, + {"other", true}, + {"invalid", false}, + {"CSAM", false}, // case-sensitive + {"Spam", false}, // case-sensitive + {"", false}, + {" spam", false}, // with space + } + + for _, tt := range tests { + t.Run(tt.reason, func(t *testing.T) { + if got := IsValidReason(tt.reason); got != tt.expected { + t.Errorf("IsValidReason(%q) = %v, want %v", tt.reason, got, tt.expected) + } + }) + } +} + +func TestIsValidStatus(t *testing.T) { + tests := []struct { + status string + expected bool + }{ + {"open", true}, + {"reviewing", true}, + {"resolved", true}, + {"dismissed", true}, + {"invalid", false}, + {"OPEN", false}, // case-sensitive + {"Resolved", false}, // case-sensitive + {"", false}, + {" open", false}, // with space + } + + for _, tt := range tests { + t.Run(tt.status, func(t *testing.T) { + if got := IsValidStatus(tt.status); got != tt.expected { + t.Errorf("IsValidStatus(%q) = %v, want %v", tt.status, got, tt.expected) + } + }) + } +} + +func TestIsValidTargetType(t *testing.T) { + tests := []struct { + targetType string + expected bool + }{ + {"post", true}, + {"comment", true}, + {"invalid", false}, + {"POST", false}, // case-sensitive + {"Comment", false}, // case-sensitive + {"", false}, + {" post", false}, // with space + } + + for _, tt := range tests { + t.Run(tt.targetType, func(t *testing.T) { + if got := IsValidTargetType(tt.targetType); got != tt.expected { + t.Errorf("IsValidTargetType(%q) = %v, want %v", tt.targetType, got, tt.expected) + } + }) + } +} + +func TestNewReport_Success(t *testing.T) { + req := SubmitReportRequest{ + ReporterDID: "did:plc:testuser123", + TargetURI: "at://did:plc:author123/social.coves.post/abc123", + Reason: "spam", + Explanation: "This is spam", + } + + report, err := NewReport(req) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + if report.ReporterDID != req.ReporterDID { + t.Errorf("expected ReporterDID %q, got %q", req.ReporterDID, report.ReporterDID) + } + if report.TargetURI != req.TargetURI { + t.Errorf("expected TargetURI %q, got %q", req.TargetURI, report.TargetURI) + } + if report.Reason != Reason(req.Reason) { + t.Errorf("expected Reason %q, got %q", req.Reason, report.Reason) + } + if report.Explanation != req.Explanation { + t.Errorf("expected Explanation %q, got %q", req.Explanation, report.Explanation) + } + if report.Status != StatusOpen { + t.Errorf("expected Status %q, got %q", StatusOpen, report.Status) + } + if report.TargetType != TargetTypePost { + t.Errorf("expected TargetType %q, got %q", TargetTypePost, report.TargetType) + } + if report.CreatedAt.IsZero() { + t.Error("expected CreatedAt to be set") + } +} + +func TestNewReport_ValidationError(t *testing.T) { + req := SubmitReportRequest{ + ReporterDID: "", + TargetURI: "at://did:plc:author123/social.coves.post/abc123", + Reason: "spam", + } + + _, err := NewReport(req) + if !errors.Is(err, ErrReporterRequired) { + t.Errorf("expected ErrReporterRequired, got %v", err) + } +} + +func TestSubmitReportRequest_Validate(t *testing.T) { + tests := []struct { + name string + req SubmitReportRequest + expectedErr error + }{ + { + name: "valid request", + req: SubmitReportRequest{ + ReporterDID: "did:plc:testuser123", + TargetURI: "at://did:plc:author123/social.coves.post/abc123", + Reason: "spam", + }, + expectedErr: nil, + }, + { + name: "empty reporter", + req: SubmitReportRequest{ + ReporterDID: "", + TargetURI: "at://did:plc:author123/social.coves.post/abc123", + Reason: "spam", + }, + expectedErr: ErrReporterRequired, + }, + { + name: "invalid reason", + req: SubmitReportRequest{ + ReporterDID: "did:plc:testuser123", + TargetURI: "at://did:plc:author123/social.coves.post/abc123", + Reason: "bad_reason", + }, + expectedErr: ErrInvalidReason, + }, + { + name: "invalid target URI", + req: SubmitReportRequest{ + ReporterDID: "did:plc:testuser123", + TargetURI: "https://example.com", + Reason: "spam", + }, + expectedErr: ErrInvalidTarget, + }, + { + name: "explanation too long", + req: SubmitReportRequest{ + ReporterDID: "did:plc:testuser123", + TargetURI: "at://did:plc:author123/social.coves.post/abc123", + Reason: "spam", + Explanation: strings.Repeat("x", MaxExplanationLength+1), + }, + expectedErr: ErrExplanationTooLong, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.req.Validate() + if tt.expectedErr == nil { + if err != nil { + t.Errorf("expected no error, got: %v", err) + } + } else { + if !errors.Is(err, tt.expectedErr) { + t.Errorf("expected error %v, got %v", tt.expectedErr, err) + } + } + }) + } +} + +func TestValidReasons(t *testing.T) { + reasons := ValidReasons() + expected := []Reason{ReasonCSAM, ReasonDoxing, ReasonHarassment, ReasonSpam, ReasonIllegal, ReasonOther} + + if len(reasons) != len(expected) { + t.Fatalf("expected %d reasons, got %d", len(expected), len(reasons)) + } + + for i, r := range expected { + if reasons[i] != r { + t.Errorf("expected reason[%d] = %q, got %q", i, r, reasons[i]) + } + } +} + +func TestValidStatuses(t *testing.T) { + statuses := ValidStatuses() + expected := []Status{StatusOpen, StatusReviewing, StatusResolved, StatusDismissed} + + if len(statuses) != len(expected) { + t.Fatalf("expected %d statuses, got %d", len(expected), len(statuses)) + } + + for i, s := range expected { + if statuses[i] != s { + t.Errorf("expected status[%d] = %q, got %q", i, s, statuses[i]) + } + } +} + +func TestValidTargetTypes(t *testing.T) { + types := ValidTargetTypes() + expected := []TargetType{TargetTypePost, TargetTypeComment} + + if len(types) != len(expected) { + t.Fatalf("expected %d target types, got %d", len(expected), len(types)) + } + + for i, tt := range expected { + if types[i] != tt { + t.Errorf("expected targetType[%d] = %q, got %q", i, tt, types[i]) + } + } +} + +func TestIsValidationError(t *testing.T) { + tests := []struct { + name string + err error + expected bool + }{ + {"ErrInvalidReason", ErrInvalidReason, true}, + {"ErrInvalidStatus", ErrInvalidStatus, true}, + {"ErrInvalidTarget", ErrInvalidTarget, true}, + {"ErrExplanationTooLong", ErrExplanationTooLong, true}, + {"ErrReporterRequired", ErrReporterRequired, true}, + {"ErrInvalidTargetType", ErrInvalidTargetType, true}, + {"ErrReportNotFound", ErrReportNotFound, false}, + {"generic error", errors.New("some error"), false}, + {"nil", nil, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := IsValidationError(tt.err); got != tt.expected { + t.Errorf("IsValidationError(%v) = %v, want %v", tt.err, got, tt.expected) + } + }) + } +} + +func TestIsNotFound(t *testing.T) { + tests := []struct { + name string + err error + expected bool + }{ + {"ErrReportNotFound", ErrReportNotFound, true}, + {"ErrInvalidReason", ErrInvalidReason, false}, + {"generic error", errors.New("some error"), false}, + {"nil", nil, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := IsNotFound(tt.err); got != tt.expected { + t.Errorf("IsNotFound(%v) = %v, want %v", tt.err, got, tt.expected) + } + }) + } +} diff --git a/internal/db/migrations/028_create_admin_reports_table.sql b/internal/db/migrations/028_create_admin_reports_table.sql new file mode 100644 index 0000000..f6431bd --- /dev/null +++ b/internal/db/migrations/028_create_admin_reports_table.sql @@ -0,0 +1,23 @@ +-- +goose Up +CREATE TABLE admin_reports ( + id BIGSERIAL PRIMARY KEY, + reporter_did TEXT NOT NULL, + target_uri TEXT NOT NULL, -- AT-URI of post/comment + target_type TEXT NOT NULL, -- 'post' or 'comment' + reason TEXT NOT NULL, -- csam, doxing, harassment, spam, illegal, other + explanation TEXT, -- optional details (max 1000 chars) + status TEXT NOT NULL DEFAULT 'open', + resolved_by TEXT, + resolution_notes TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + resolved_at TIMESTAMPTZ, + + CONSTRAINT valid_reason CHECK (reason IN ('csam', 'doxing', 'harassment', 'spam', 'illegal', 'other')), + CONSTRAINT valid_status CHECK (status IN ('open', 'reviewing', 'resolved', 'dismissed')) +); + +CREATE INDEX idx_admin_reports_status_created ON admin_reports(status, created_at DESC); +CREATE INDEX idx_admin_reports_target ON admin_reports(target_uri); + +-- +goose Down +DROP TABLE IF EXISTS admin_reports; diff --git a/internal/db/postgres/admin_report_repo.go b/internal/db/postgres/admin_report_repo.go new file mode 100644 index 0000000..4cb9bb4 --- /dev/null +++ b/internal/db/postgres/admin_report_repo.go @@ -0,0 +1,222 @@ +package postgres + +import ( + "Coves/internal/core/adminreports" + "context" + "database/sql" + "errors" + "fmt" + "log/slog" + "strings" + "time" + + "github.com/lib/pq" +) + +type postgresAdminReportRepo struct { + db *sql.DB +} + +// NewAdminReportRepository creates a new PostgreSQL admin report repository +func NewAdminReportRepository(db *sql.DB) adminreports.Repository { + return &postgresAdminReportRepo{db: db} +} + +// Create inserts a new admin report into the database +// Returns the created report with ID and CreatedAt populated +func (r *postgresAdminReportRepo) Create(ctx context.Context, report *adminreports.Report) error { + query := ` + INSERT INTO admin_reports ( + reporter_did, target_uri, target_type, + reason, explanation, status + ) VALUES ( + $1, $2, $3, + $4, $5, $6 + ) + RETURNING id, created_at + ` + + // Default status to 'open' if not set + status := report.Status + if status == "" { + status = adminreports.StatusOpen + } + + // Handle empty explanation as NULL + var explanation *string + if report.Explanation != "" { + explanation = &report.Explanation + } + + err := r.db.QueryRowContext( + ctx, query, + report.ReporterDID, report.TargetURI, string(report.TargetType), + string(report.Reason), explanation, string(status), + ).Scan(&report.ID, &report.CreatedAt) + + if err != nil { + // Check for constraint violations using pq.Error type + if pqErr := extractPQError(err); pqErr != nil { + if strings.Contains(pqErr.Constraint, "valid_reason") { + return adminreports.ErrInvalidReason + } + if strings.Contains(pqErr.Constraint, "valid_status") { + return adminreports.ErrInvalidStatus + } + if strings.Contains(pqErr.Constraint, "valid_target_type") { + return adminreports.ErrInvalidTargetType + } + } + return fmt.Errorf("failed to create admin report: %w", err) + } + + report.Status = status + return nil +} + +// ListByStatus retrieves reports filtered by status with pagination +// Results are ordered by created_at DESC (newest first) +func (r *postgresAdminReportRepo) ListByStatus(ctx context.Context, status string, limit, offset int) ([]*adminreports.Report, error) { + query := ` + SELECT + id, reporter_did, target_uri, target_type, + reason, explanation, status, + resolved_by, resolution_notes, + created_at, resolved_at + FROM admin_reports + WHERE status = $1 + ORDER BY created_at DESC + LIMIT $2 OFFSET $3 + ` + + rows, err := r.db.QueryContext(ctx, query, status, limit, offset) + if err != nil { + return nil, fmt.Errorf("failed to list admin reports by status: %w", err) + } + defer func() { + if closeErr := rows.Close(); closeErr != nil { + slog.Warn("failed to close rows in ListByStatus", + slog.String("error", closeErr.Error()), + ) + } + }() + + var reports []*adminreports.Report + for rows.Next() { + report, err := scanReport(rows) + if err != nil { + return nil, err + } + reports = append(reports, report) + } + + if err = rows.Err(); err != nil { + return nil, fmt.Errorf("error iterating admin reports: %w", err) + } + + return reports, nil +} + +// UpdateStatus updates a report's status and resolution details +// Sets resolved_by, resolution_notes, and resolved_at when resolving or dismissing +func (r *postgresAdminReportRepo) UpdateStatus(ctx context.Context, id int64, status, resolvedBy, notes string) error { + var query string + var args []interface{} + + // When resolving or dismissing, set resolved_at and resolution fields + if status == string(adminreports.StatusResolved) || status == string(adminreports.StatusDismissed) { + query = ` + UPDATE admin_reports + SET status = $1, + resolved_by = $2, + resolution_notes = $3, + resolved_at = $4 + WHERE id = $5 + ` + args = []interface{}{status, resolvedBy, notes, time.Now(), id} + } else { + // For other status changes (e.g., open -> reviewing), don't set resolution fields + query = ` + UPDATE admin_reports + SET status = $1 + WHERE id = $2 + ` + args = []interface{}{status, id} + } + + result, err := r.db.ExecContext(ctx, query, args...) + if err != nil { + // Check for constraint violations using pq.Error type + if pqErr := extractPQError(err); pqErr != nil { + if strings.Contains(pqErr.Constraint, "valid_status") { + return adminreports.ErrInvalidStatus + } + if strings.Contains(pqErr.Constraint, "valid_reason") { + return adminreports.ErrInvalidReason + } + } + return fmt.Errorf("failed to update admin report status: %w", err) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("failed to check update result: %w", err) + } + + if rowsAffected == 0 { + return adminreports.ErrReportNotFound + } + + return nil +} + +// scanReport scans a single report from a database row +func scanReport(rows *sql.Rows) (*adminreports.Report, error) { + var report adminreports.Report + var targetType, reason, status string + var explanation sql.NullString + var resolvedBy sql.NullString + var resolutionNotes sql.NullString + var resolvedAt sql.NullTime + + err := rows.Scan( + &report.ID, &report.ReporterDID, &report.TargetURI, &targetType, + &reason, &explanation, &status, + &resolvedBy, &resolutionNotes, + &report.CreatedAt, &resolvedAt, + ) + if err != nil { + return nil, fmt.Errorf("failed to scan admin report: %w", err) + } + + // Convert string values to typed enums + report.TargetType = adminreports.TargetType(targetType) + report.Reason = adminreports.Reason(reason) + report.Status = adminreports.Status(status) + + // Convert nullable fields + if explanation.Valid { + report.Explanation = explanation.String + } + if resolvedBy.Valid { + report.ResolvedBy = &resolvedBy.String + } + if resolutionNotes.Valid { + report.ResolutionNotes = &resolutionNotes.String + } + if resolvedAt.Valid { + report.ResolvedAt = &resolvedAt.Time + } + + return &report, nil +} + +// extractPQError extracts a pq.Error from an error if present +// Returns nil if the error is not a pq.Error +func extractPQError(err error) *pq.Error { + var pqErr *pq.Error + if errors.As(err, &pqErr) { + return pqErr + } + return nil +} diff --git a/internal/db/postgres/comment_repo.go b/internal/db/postgres/comment_repo.go index 0667884..2d75f19 100644 --- a/internal/db/postgres/comment_repo.go +++ b/internal/db/postgres/comment_repo.go @@ -341,11 +341,12 @@ func (r *postgresCommentRepo) ListByParent(ctx context.Context, parentURI string // CountByParent counts direct replies to a post or comment // Used for showing reply counts in threading UI +// NOTE: Includes deleted comments since they're shown as "[deleted]" placeholders func (r *postgresCommentRepo) CountByParent(ctx context.Context, parentURI string) (int, error) { query := ` SELECT COUNT(*) FROM comments - WHERE parent_uri = $1 AND deleted_at IS NULL + WHERE parent_uri = $1 ` var count int @@ -617,12 +618,11 @@ func (r *postgresCommentRepo) ListByParentWithHotRank( // Build complete query with JOINs and filters // LEFT JOIN prevents data loss when user record hasn't been indexed yet (out-of-order Jetstream events) - // Excludes deleted top-level comments - deleted nested comments are preserved via ListByParentsBatch + // Includes deleted comments to preserve thread structure (shown as "[deleted]" placeholders) query := fmt.Sprintf(` %s LEFT JOIN users u ON c.commenter_did = u.did WHERE c.parent_uri = $1 - AND c.deleted_at IS NULL %s %s ORDER BY %s diff --git a/tests/integration/comment_consumer_test.go b/tests/integration/comment_consumer_test.go index 2a9dd6a..754929c 100644 --- a/tests/integration/comment_consumer_test.go +++ b/tests/integration/comment_consumer_test.go @@ -470,7 +470,7 @@ func TestCommentConsumer_DeleteComment(t *testing.T) { } testPostURI := createTestPost(t, db, testCommunity, testUser.DID, "Delete Test", 0, time.Now()) - t.Run("Delete comment decrements parent count", func(t *testing.T) { + t.Run("Delete comment preserves parent count (deleted shown as placeholder)", func(t *testing.T) { rkey := generateTID() uri := fmt.Sprintf("at://%s/social.coves.community.comment/%s", testUser.DID, rkey) @@ -538,15 +538,17 @@ func TestCommentConsumer_DeleteComment(t *testing.T) { t.Error("Expected deleted_at to be set, got nil") } - // Verify post comment count decremented + // Verify post comment count is PRESERVED (not decremented) + // Deleted comments are shown as "[deleted]" placeholders to preserve thread structure, + // so they should still count toward the displayed total. var finalCount int err = db.QueryRowContext(ctx, "SELECT comment_count FROM posts WHERE uri = $1", testPostURI).Scan(&finalCount) if err != nil { t.Fatalf("Failed to get final comment count: %v", err) } - if finalCount != initialCount-1 { - t.Errorf("Expected comment count to decrease by 1. Initial: %d, Final: %d", initialCount, finalCount) + if finalCount != initialCount { + t.Errorf("Expected comment count to be PRESERVED (deleted = placeholder). Initial: %d, Final: %d", initialCount, finalCount) } }) @@ -1541,13 +1543,13 @@ func TestCommentConsumer_Resurrection(t *testing.T) { t.Fatalf("Failed to delete comment: %v", err) } - // Verify Post 1 count decremented to 0 + // Verify Post 1 count is PRESERVED at 1 (deleted comments shown as "[deleted]" placeholders) err = db.QueryRowContext(ctx, "SELECT comment_count FROM posts WHERE uri = $1", post1URI).Scan(&post1Count) if err != nil { t.Fatalf("Failed to check post 1 count after delete: %v", err) } - if post1Count != 0 { - t.Errorf("Expected Post 1 comment_count = 0 after delete, got %d", post1Count) + if post1Count != 1 { + t.Errorf("Expected Post 1 comment_count = 1 after delete (preserved for placeholder), got %d", post1Count) } // Step 3: Recreate comment with same rkey but on Post 2 (different parent!) @@ -1610,13 +1612,14 @@ func TestCommentConsumer_Resurrection(t *testing.T) { t.Errorf("Expected Post 2 comment_count = 1, got %d", post2Count) } - // Verify Post 1 count still 0 (not incremented by resurrection on Post 2) + // Verify Post 1 count still 1 (preserved from before resurrection on Post 2) + // The resurrection on Post 2 does not affect Post 1's count err = db.QueryRowContext(ctx, "SELECT comment_count FROM posts WHERE uri = $1", post1URI).Scan(&post1Count) if err != nil { t.Fatalf("Failed to check post 1 count after resurrection: %v", err) } - if post1Count != 0 { - t.Errorf("Expected Post 1 comment_count = 0 (unchanged), got %d", post1Count) + if post1Count != 1 { + t.Errorf("Expected Post 1 comment_count = 1 (preserved, unchanged by Post 2 resurrection), got %d", post1Count) } }) } diff --git a/tests/integration/comment_query_test.go b/tests/integration/comment_query_test.go index 7cd46bc..90d0566 100644 --- a/tests/integration/comment_query_test.go +++ b/tests/integration/comment_query_test.go @@ -605,20 +605,39 @@ func TestCommentQuery_DeletedComments(t *testing.T) { resp, err := service.GetComments(ctx, req) require.NoError(t, err) - // Verify only 3 comments returned (2 were deleted) - assert.Len(t, resp.Comments, 3, "Should only return non-deleted comments") + // All 5 comments should be returned (deleted comments shown as placeholders) + assert.Len(t, resp.Comments, 5, "Should return all comments including deleted ones as placeholders") - // Verify deleted comments are not in results - returnedURIs := make(map[string]bool) + // Build a map of URI -> CommentView for verification + commentViews := make(map[string]*comments.CommentView) for _, tv := range resp.Comments { - returnedURIs[tv.Comment.URI] = true + commentViews[tv.Comment.URI] = tv.Comment } - assert.False(t, returnedURIs[commentURIs[1]], "Deleted comment 1 should not be in results") - assert.False(t, returnedURIs[commentURIs[3]], "Deleted comment 3 should not be in results") - assert.True(t, returnedURIs[commentURIs[0]], "Non-deleted comment 0 should be in results") - assert.True(t, returnedURIs[commentURIs[2]], "Non-deleted comment 2 should be in results") - assert.True(t, returnedURIs[commentURIs[4]], "Non-deleted comment 4 should be in results") + // Verify all comments are present + assert.Contains(t, commentViews, commentURIs[0], "Comment 0 should be in results") + assert.Contains(t, commentViews, commentURIs[1], "Comment 1 (deleted) should be in results as placeholder") + assert.Contains(t, commentViews, commentURIs[2], "Comment 2 should be in results") + assert.Contains(t, commentViews, commentURIs[3], "Comment 3 (deleted) should be in results as placeholder") + assert.Contains(t, commentViews, commentURIs[4], "Comment 4 should be in results") + + // Verify deleted comments are marked as deleted + assert.True(t, commentViews[commentURIs[1]].IsDeleted, "Deleted comment 1 should have IsDeleted=true") + assert.True(t, commentViews[commentURIs[3]].IsDeleted, "Deleted comment 3 should have IsDeleted=true") + + // Verify non-deleted comments are NOT marked as deleted + assert.False(t, commentViews[commentURIs[0]].IsDeleted, "Non-deleted comment 0 should have IsDeleted=false") + assert.False(t, commentViews[commentURIs[2]].IsDeleted, "Non-deleted comment 2 should have IsDeleted=false") + assert.False(t, commentViews[commentURIs[4]].IsDeleted, "Non-deleted comment 4 should have IsDeleted=false") + + // Verify deleted comments have nil Record (content cleared) + assert.Nil(t, commentViews[commentURIs[1]].Record, "Deleted comment 1 should have nil Record") + assert.Nil(t, commentViews[commentURIs[3]].Record, "Deleted comment 3 should have nil Record") + + // Verify non-deleted comments have content + assert.NotNil(t, commentViews[commentURIs[0]].Record, "Non-deleted comment 0 should have Record") + assert.NotNil(t, commentViews[commentURIs[2]].Record, "Non-deleted comment 2 should have Record") + assert.NotNil(t, commentViews[commentURIs[4]].Record, "Non-deleted comment 4 should have Record") } // TestCommentQuery_InvalidInputs tests error handling for invalid inputs