diff --git a/docs/plans/2026-02-18-feedback-form-implementation.md b/docs/plans/2026-02-18-feedback-form-implementation.md new file mode 100644 index 00000000..80135d1d --- /dev/null +++ b/docs/plans/2026-02-18-feedback-form-implementation.md @@ -0,0 +1,817 @@ +# Feedback Form Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add a feedback form page where logged-in users can submit feedback that gets emailed to a configurable address via the existing Resend infrastructure. + +**Architecture:** New `POST /feedback` backend endpoint validates input and sends an HTML email via the existing `EmailService`. Frontend is a single `FeedbackPage.tsx` form page accessible from the sidebar nav and account settings. No database storage — email-only. + +**Tech Stack:** FastAPI + Pydantic (backend), React + TypeScript + Tailwind (frontend), Resend (email delivery), slowapi (rate limiting) + +**Design doc:** `docs/plans/2026-02-18-feedback-form-design.md` + +--- + +## Task 1: Backend Schema + +**Files:** +- Create: `backend/app/schemas/feedback.py` + +**Step 1: Create the Pydantic schema** + +```python +from enum import Enum +from pydantic import BaseModel, EmailStr, Field + + +class FeedbackType(str, Enum): + BUG_REPORT = "Bug Report" + FEATURE_REQUEST = "Feature Request" + USABILITY_ISSUE = "Usability Issue" + DOCUMENTATION = "Documentation" + GENERAL = "General Feedback" + + +class FeedbackSubmission(BaseModel): + email: EmailStr + feedback_type: FeedbackType + message: str = Field(..., min_length=10, max_length=5000) + + +class FeedbackResponse(BaseModel): + success: bool + message: str +``` + +**Step 2: Commit** + +```bash +git add backend/app/schemas/feedback.py +git commit -m "feat: add feedback submission schema" +``` + +--- + +## Task 2: Config — Add FEEDBACK_EMAIL + +**Files:** +- Modify: `backend/app/core/config.py` + +**Step 1: Add the FEEDBACK_EMAIL setting** + +In `backend/app/core/config.py`, add this line in the `Settings` class after the `FROM_EMAIL` line (line 57): + +```python + FEEDBACK_EMAIL: Optional[str] = None +``` + +**Step 2: Commit** + +```bash +git add backend/app/core/config.py +git commit -m "feat: add FEEDBACK_EMAIL config setting" +``` + +--- + +## Task 3: Email Service — Add send_feedback_email + +**Files:** +- Modify: `backend/app/core/email.py` + +**Step 1: Add the send_feedback_email method** + +Add this method to the `EmailService` class (after `send_account_invite_email`, before the helper functions): + +```python + @staticmethod + async def send_feedback_email( + to_email: str, + reply_to_email: str, + feedback_type: str, + message: str, + user_email: str, + account_name: str | None = None, + account_code: str | None = None, + ) -> bool: + if not settings.email_enabled: + logger.warning("Email not sent — RESEND_API_KEY not configured") + return False + + try: + import resend + from datetime import datetime, timezone + + resend.api_key = settings.RESEND_API_KEY + + date_str = datetime.now(timezone.utc).strftime("%Y-%m-%d") + code_suffix = f" — {account_code}" if account_code else "" + subject = f"[ResolutionFlow Feedback] {feedback_type} — {date_str}{code_suffix}" + + html = _render_feedback_html( + feedback_type=feedback_type, + message=message, + user_email=user_email, + account_name=account_name, + account_code=account_code, + ) + + resend.Emails.send( + { + "from": settings.FROM_EMAIL, + "to": [to_email], + "reply_to": reply_to_email, + "subject": subject, + "html": html, + } + ) + logger.info("Feedback email sent from %s (type: %s)", user_email, feedback_type) + return True + + except Exception: + logger.exception("Failed to send feedback email from %s", user_email) + return False +``` + +**Step 2: Add the HTML renderer** + +Add this function at the bottom of the file (after the other `_render_*` functions): + +```python +def _render_feedback_html( + feedback_type: str, + message: str, + user_email: str, + account_name: str | None, + account_code: str | None, +) -> str: + from datetime import datetime, timezone + import html + + date_str = datetime.now(timezone.utc).strftime("%B %d, %Y") + safe_message = html.escape(message).replace("\n", "
") + + account_line = "" + if account_name and account_code: + account_line = f""" + +

+ Account: {html.escape(account_name)} ({html.escape(account_code)}) +

+ """ + + return f""" + + + + +
+ + + + + {account_line} + + + +
+

ResolutionFlow Feedback

+
+

+ Type: {html.escape(feedback_type)} +

+
+

+ From: {html.escape(user_email)} +

+
+

+ Date: {date_str} +

+
+
+

{safe_message}

+
+
+

+ Reply directly to this email to respond to the user. +

+
+
+""" +``` + +**Step 3: Commit** + +```bash +git add backend/app/core/email.py +git commit -m "feat: add send_feedback_email to EmailService" +``` + +--- + +## Task 4: Backend Endpoint + +**Files:** +- Create: `backend/app/api/endpoints/feedback.py` +- Modify: `backend/app/api/router.py` + +**Step 1: Create the endpoint** + +Create `backend/app/api/endpoints/feedback.py`: + +```python +import logging +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, Request, status +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select + +from app.api.deps import get_current_active_user +from app.core.config import settings +from app.core.database import get_db +from app.core.email import EmailService +from app.core.rate_limit import limiter +from app.models.user import User +from app.models.account import Account +from app.schemas.feedback import FeedbackSubmission, FeedbackResponse + +logger = logging.getLogger(__name__) + +router = APIRouter(tags=["feedback"]) + + +@router.post("/feedback", response_model=FeedbackResponse) +@limiter.limit("1/minute") +async def submit_feedback( + request: Request, + data: FeedbackSubmission, + current_user: Annotated[User, Depends(get_current_active_user)], + db: Annotated[AsyncSession, Depends(get_db)], +): + """Submit user feedback via email.""" + if not settings.FEEDBACK_EMAIL: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Feedback submission is not configured", + ) + + # Get account info for the email + account_name = None + account_code = None + if current_user.account_id: + result = await db.execute( + select(Account).where(Account.id == current_user.account_id) + ) + account = result.scalar_one_or_none() + if account: + account_name = account.name + account_code = account.display_code + + sent = await EmailService.send_feedback_email( + to_email=settings.FEEDBACK_EMAIL, + reply_to_email=data.email, + feedback_type=data.feedback_type.value, + message=data.message, + user_email=current_user.email, + account_name=account_name, + account_code=account_code, + ) + + if not sent: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to send feedback. Please try again later.", + ) + + return FeedbackResponse(success=True, message="Thank you! Your feedback has been submitted.") +``` + +**Step 2: Register the router** + +In `backend/app/api/router.py`, add the import and include: + +Add to imports (line 6, after the existing imports): +```python +from app.api.endpoints import feedback +``` + +Add at the end of the router registrations: +```python +api_router.include_router(feedback.router) +``` + +**Step 3: Commit** + +```bash +git add backend/app/api/endpoints/feedback.py backend/app/api/router.py +git commit -m "feat: add POST /feedback endpoint" +``` + +--- + +## Task 5: Backend Test + +**Files:** +- Create: `backend/tests/test_feedback.py` + +**Step 1: Write the test** + +```python +import pytest +from unittest.mock import patch, AsyncMock + + +@pytest.mark.asyncio +async def test_submit_feedback(async_client, engineer_token, monkeypatch): + """Test successful feedback submission.""" + monkeypatch.setenv("FEEDBACK_EMAIL", "support@test.com") + # Reload settings to pick up the env var + from app.core.config import Settings + test_settings = Settings() + + with patch("app.api.endpoints.feedback.settings") as mock_settings, \ + patch("app.api.endpoints.feedback.EmailService") as mock_email: + mock_settings.FEEDBACK_EMAIL = "support@test.com" + mock_email.send_feedback_email = AsyncMock(return_value=True) + + response = await async_client.post( + "/api/v1/feedback", + json={ + "email": "engineer@resolutionflow.example.com", + "feedback_type": "Bug Report", + "message": "Something is broken in the tree editor when I try to save.", + }, + headers={"Authorization": f"Bearer {engineer_token}"}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert "submitted" in data["message"].lower() + + +@pytest.mark.asyncio +async def test_submit_feedback_not_configured(async_client, engineer_token): + """Test 503 when FEEDBACK_EMAIL is not set.""" + with patch("app.api.endpoints.feedback.settings") as mock_settings: + mock_settings.FEEDBACK_EMAIL = None + + response = await async_client.post( + "/api/v1/feedback", + json={ + "email": "engineer@resolutionflow.example.com", + "feedback_type": "General Feedback", + "message": "This is a general feedback message for testing.", + }, + headers={"Authorization": f"Bearer {engineer_token}"}, + ) + + assert response.status_code == 503 + + +@pytest.mark.asyncio +async def test_submit_feedback_validation(async_client, engineer_token): + """Test validation — message too short.""" + with patch("app.api.endpoints.feedback.settings") as mock_settings: + mock_settings.FEEDBACK_EMAIL = "support@test.com" + + response = await async_client.post( + "/api/v1/feedback", + json={ + "email": "engineer@resolutionflow.example.com", + "feedback_type": "Bug Report", + "message": "short", + }, + headers={"Authorization": f"Bearer {engineer_token}"}, + ) + + assert response.status_code == 422 + + +@pytest.mark.asyncio +async def test_submit_feedback_invalid_type(async_client, engineer_token): + """Test validation — invalid feedback type.""" + with patch("app.api.endpoints.feedback.settings") as mock_settings: + mock_settings.FEEDBACK_EMAIL = "support@test.com" + + response = await async_client.post( + "/api/v1/feedback", + json={ + "email": "engineer@resolutionflow.example.com", + "feedback_type": "Invalid Type", + "message": "This should fail because the type is invalid.", + }, + headers={"Authorization": f"Bearer {engineer_token}"}, + ) + + assert response.status_code == 422 + + +@pytest.mark.asyncio +async def test_submit_feedback_requires_auth(async_client): + """Test that unauthenticated requests are rejected.""" + response = await async_client.post( + "/api/v1/feedback", + json={ + "email": "anon@example.com", + "feedback_type": "General Feedback", + "message": "This should fail because I'm not logged in.", + }, + ) + assert response.status_code == 401 +``` + +**Step 2: Run the tests** + +```bash +cd backend && pytest tests/test_feedback.py -v --override-ini="addopts=" +``` + +Expected: All 5 tests pass. If any fail, debug and fix before proceeding. + +**Step 3: Commit** + +```bash +git add backend/tests/test_feedback.py +git commit -m "test: add feedback endpoint tests" +``` + +--- + +## Task 6: Frontend API Client + +**Files:** +- Create: `frontend/src/api/feedback.ts` +- Modify: `frontend/src/api/index.ts` + +**Step 1: Create the API module** + +Create `frontend/src/api/feedback.ts`: + +```typescript +import { apiClient } from './client' + +export interface FeedbackSubmission { + email: string + feedback_type: string + message: string +} + +export interface FeedbackResponse { + success: boolean + message: string +} + +export const feedbackApi = { + submit: async (data: FeedbackSubmission): Promise => { + const { data: response } = await apiClient.post('/feedback', data) + return response + }, +} + +export default feedbackApi +``` + +**Step 2: Export from index** + +In `frontend/src/api/index.ts`, add at the end: + +```typescript +export { default as feedbackApi } from './feedback' +``` + +**Step 3: Commit** + +```bash +git add frontend/src/api/feedback.ts frontend/src/api/index.ts +git commit -m "feat: add feedback API client" +``` + +--- + +## Task 7: Frontend Page + +**Files:** +- Create: `frontend/src/pages/FeedbackPage.tsx` + +**Step 1: Create the page component** + +```tsx +import { useState } from 'react' +import { MessageSquareText, Send, CheckCircle2 } from 'lucide-react' +import { useAuthStore } from '@/store/authStore' +import { feedbackApi } from '@/api' +import { cn } from '@/lib/utils' +import { toast } from '@/lib/toast' + +const FEEDBACK_TYPES = [ + 'Bug Report', + 'Feature Request', + 'Usability Issue', + 'Documentation', + 'General Feedback', +] as const + +export function FeedbackPage() { + const user = useAuthStore(s => s.user) + + const [email, setEmail] = useState(user?.email ?? '') + const [feedbackType, setFeedbackType] = useState('') + const [message, setMessage] = useState('') + const [isSubmitting, setIsSubmitting] = useState(false) + const [submitted, setSubmitted] = useState(false) + + const canSubmit = email.trim() && feedbackType && message.trim().length >= 10 + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + if (!canSubmit || isSubmitting) return + + setIsSubmitting(true) + try { + const response = await feedbackApi.submit({ + email: email.trim(), + feedback_type: feedbackType, + message: message.trim(), + }) + if (response.success) { + setSubmitted(true) + setFeedbackType('') + setMessage('') + } + } catch (err: unknown) { + const error = err as { response?: { data?: { detail?: string } } } + toast.error(error.response?.data?.detail || 'Failed to submit feedback. Please try again.') + } finally { + setIsSubmitting(false) + } + } + + const handleNewFeedback = () => { + setSubmitted(false) + setEmail(user?.email ?? '') + } + + return ( +
+ {/* Page header */} +
+
+ +

Send Feedback

+
+

+ Help us improve ResolutionFlow. Report bugs, request features, or share your thoughts. +

+
+ +
+ {submitted ? ( +
+ +

Thank you for your feedback!

+

+ We've received your submission and will review it shortly. +

+ +
+ ) : ( +
+ {/* Email */} +
+ + setEmail(e.target.value)} + placeholder="your@email.com" + required + className="w-full rounded-lg border border-border bg-card px-3 py-2 text-foreground placeholder:text-muted-foreground focus:border-primary focus:ring-1 focus:ring-primary/20 focus:outline-none" + /> +

We'll reply to this address if we need more details.

+
+ + {/* Feedback Type */} +
+ + +
+ + {/* Message */} +
+ +