# 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 stored in the database and emailed to a configurable address via the existing Resend infrastructure. Includes confirmation email to the submitter, helper text on feedback types, and TODO breadcrumbs for future post-session contextual feedback. **Architecture:** New `POST /feedback` backend endpoint validates input, writes to a `feedback` table, sends an HTML notification email to the configured address, and fires a confirmation email back to the submitter. Email failures do NOT prevent the DB write from succeeding. Frontend is a single `FeedbackPage.tsx` form page with a custom feedback type selector (with descriptions) accessible from the sidebar nav and account settings. **Tech Stack:** FastAPI + Pydantic + Alembic (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: Database Model & Migration **Files:** - Create: `backend/app/models/feedback.py` - Modify: `backend/app/models/__init__.py` - Create: Alembic migration (manual) **Step 1: Create the SQLAlchemy model** Create `backend/app/models/feedback.py`: ```python import uuid from datetime import datetime, timezone from typing import Optional from sqlalchemy import String, Text, DateTime, ForeignKey from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.dialects.postgresql import UUID from app.core.database import Base class Feedback(Base): __tablename__ = "feedback" id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) account_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("accounts.id", ondelete="SET NULL"), nullable=True) user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=False) email: Mapped[str] = mapped_column(String(255), nullable=False) feedback_type: Mapped[str] = mapped_column(String(50), nullable=False) message: Mapped[str] = mapped_column(Text, nullable=False) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) ``` **Step 2: Register the model** In `backend/app/models/__init__.py`, add the import and export: Add import: ```python from .feedback import Feedback ``` Add `"Feedback"` to the `__all__` list. **Step 3: Create the migration manually** Run: ```bash cd backend && alembic revision -m "add feedback table" ``` Then edit the generated migration file: ```python """add feedback table Revision ID: Revises: 7e00fa3c75c9 Create Date: 2026-02-18 """ from alembic import op import sqlalchemy as sa from sqlalchemy.dialects import postgresql # revision identifiers, used by Alembic. revision = '' down_revision = '7e00fa3c75c9' branch_labels = None depends_on = None def upgrade() -> None: op.create_table( 'feedback', sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True), sa.Column('account_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('accounts.id', ondelete='SET NULL'), nullable=True), sa.Column('user_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('users.id', ondelete='SET NULL'), nullable=False), sa.Column('email', sa.String(255), nullable=False), sa.Column('feedback_type', sa.String(50), nullable=False), sa.Column('message', sa.Text(), nullable=False), sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()), ) op.create_index('ix_feedback_account_id', 'feedback', ['account_id']) op.create_index('ix_feedback_user_id', 'feedback', ['user_id']) op.create_index('ix_feedback_created_at', 'feedback', ['created_at']) def downgrade() -> None: op.drop_index('ix_feedback_created_at', table_name='feedback') op.drop_index('ix_feedback_user_id', table_name='feedback') op.drop_index('ix_feedback_account_id', table_name='feedback') op.drop_table('feedback') ``` **Step 4: Run the migration** ```bash cd backend && alembic upgrade head ``` **Step 5: Commit** ```bash git add backend/app/models/feedback.py backend/app/models/__init__.py backend/alembic/versions/*feedback*.py git commit -m "feat: add feedback database model and migration" ``` --- ## Task 3: 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 4: Email Service — Add feedback emails **Files:** - Modify: `backend/app/core/email.py` **Step 1: Add the send_feedback_email method (admin notification)** 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 send_feedback_confirmation_email method (user confirmation)** Add this method right after `send_feedback_email`: ```python @staticmethod async def send_feedback_confirmation_email( to_email: str, feedback_type: str, message_preview: str, ) -> bool: """Send a thank-you confirmation to the feedback submitter. Fire-and-forget.""" if not settings.email_enabled: logger.warning("Confirmation email not sent — RESEND_API_KEY not configured") return False try: import resend resend.api_key = settings.RESEND_API_KEY subject = "Thanks for your feedback — ResolutionFlow" html = _render_feedback_confirmation_html( feedback_type=feedback_type, message_preview=message_preview, ) resend.Emails.send( { "from": settings.FROM_EMAIL, "to": [to_email], "subject": subject, "html": html, } ) logger.info("Feedback confirmation email sent to %s", to_email) return True except Exception: logger.exception("Failed to send feedback confirmation to %s", to_email) return False ``` **Step 3: Add the HTML renderers** Add these functions 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.

""" def _render_feedback_confirmation_html( feedback_type: str, message_preview: str, ) -> str: import html safe_preview = html.escape(message_preview) return f"""

ResolutionFlow

Thanks for your feedback!

We've received your {html.escape(feedback_type)} and our team will review it shortly.

Your feedback

"{safe_preview}"

If we need more details, we'll reach out to you directly.

""" ``` **Step 4: Commit** ```bash git add backend/app/core/email.py git commit -m "feat: add feedback notification and confirmation emails to EmailService" ``` --- ## Task 5: 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.models.feedback import Feedback from app.schemas.feedback import FeedbackSubmission, FeedbackResponse logger = logging.getLogger(__name__) router = APIRouter(tags=["feedback"]) # TODO: Post-session contextual feedback prompt — when building the post-session # feedback flow, reuse this endpoint by adding optional session_id/tree_id fields # to FeedbackSubmission. The Feedback model and email infrastructure are already # in place. See design doc for details. @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. Saves to DB and sends notification 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 # Always persist to DB first — email failure should not lose feedback feedback_record = Feedback( account_id=current_user.account_id, user_id=current_user.id, email=data.email, feedback_type=data.feedback_type.value, message=data.message, ) db.add(feedback_record) await db.commit() # Send notification email to admin (best-effort) 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: logger.warning("Feedback saved to DB but notification email failed for user %s", current_user.email) # Send confirmation email to submitter (fire-and-forget) message_preview = data.message[:100] + ("..." if len(data.message) > 100 else "") await EmailService.send_feedback_confirmation_email( to_email=data.email, feedback_type=data.feedback_type.value, message_preview=message_preview, ) 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 (after the existing import lines): ```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 with DB persistence and dual emails" ``` --- ## Task 6: Backend Tests **Files:** - Create: `backend/tests/test_feedback.py` **Step 1: Write the tests** These tests use the project's existing `client` and `auth_headers` fixtures from `conftest.py`. ```python import pytest from unittest.mock import patch, AsyncMock @pytest.mark.asyncio async def test_submit_feedback(client, auth_headers): """Test successful feedback submission — saves to DB and sends emails.""" 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) mock_email.send_feedback_confirmation_email = AsyncMock(return_value=True) response = await client.post( "/api/v1/feedback", json={ "email": "test@example.com", "feedback_type": "Bug Report", "message": "Something is broken in the tree editor when I try to save.", }, headers=auth_headers, ) assert response.status_code == 200 data = response.json() assert data["success"] is True assert "submitted" in data["message"].lower() # Verify both emails were called mock_email.send_feedback_email.assert_called_once() mock_email.send_feedback_confirmation_email.assert_called_once() @pytest.mark.asyncio async def test_submit_feedback_saves_to_db_even_if_email_fails(client, auth_headers, test_db): """Test that feedback is persisted even when email sending fails.""" from sqlalchemy import select, func from app.models.feedback import Feedback 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=False) mock_email.send_feedback_confirmation_email = AsyncMock(return_value=False) response = await client.post( "/api/v1/feedback", json={ "email": "test@example.com", "feedback_type": "Feature Request", "message": "Please add dark mode to the export preview screen.", }, headers=auth_headers, ) # Should still succeed — DB write happened assert response.status_code == 200 assert response.json()["success"] is True # Verify it was saved to the database result = await test_db.execute(select(func.count()).select_from(Feedback)) count = result.scalar() assert count >= 1 @pytest.mark.asyncio async def test_submit_feedback_not_configured(client, auth_headers): """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 client.post( "/api/v1/feedback", json={ "email": "test@example.com", "feedback_type": "General Feedback", "message": "This is a general feedback message for testing.", }, headers=auth_headers, ) assert response.status_code == 503 @pytest.mark.asyncio async def test_submit_feedback_validation_message_too_short(client, auth_headers): """Test validation — message too short.""" response = await client.post( "/api/v1/feedback", json={ "email": "test@example.com", "feedback_type": "Bug Report", "message": "short", }, headers=auth_headers, ) assert response.status_code == 422 @pytest.mark.asyncio async def test_submit_feedback_invalid_type(client, auth_headers): """Test validation — invalid feedback type.""" response = await client.post( "/api/v1/feedback", json={ "email": "test@example.com", "feedback_type": "Invalid Type", "message": "This should fail because the type is invalid.", }, headers=auth_headers, ) assert response.status_code == 422 @pytest.mark.asyncio async def test_submit_feedback_requires_auth(client): """Test that unauthenticated requests are rejected.""" response = await 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 6 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 including DB persistence" ``` --- ## Task 7: 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 8: Frontend Page **Files:** - Create: `frontend/src/pages/FeedbackPage.tsx` **Step 1: Create the page component** This version uses a custom feedback type selector with helper/description text instead of a plain ` 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 — custom selector with descriptions */}
{typeDropdownOpen && (
{FEEDBACK_TYPES.map(type => ( ))}
)}
{/* Message */}