diff --git a/docs/plans/2026-02-18-feedback-form-implementation.md b/docs/plans/2026-02-18-feedback-form-implementation.md index 80135d1d..b4bcaa37 100644 --- a/docs/plans/2026-02-18-feedback-form-implementation.md +++ b/docs/plans/2026-02-18-feedback-form-implementation.md @@ -2,11 +2,11 @@ > **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. +**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 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. +**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 (backend), React + TypeScript + Tailwind (frontend), Resend (email delivery), slowapi (rate limiting) +**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` @@ -52,7 +52,117 @@ git commit -m "feat: add feedback submission schema" --- -## Task 2: Config — Add FEEDBACK_EMAIL +## 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` @@ -74,12 +184,12 @@ git commit -m "feat: add FEEDBACK_EMAIL config setting" --- -## Task 3: Email Service — Add send_feedback_email +## Task 4: Email Service — Add feedback emails **Files:** - Modify: `backend/app/core/email.py` -**Step 1: Add the send_feedback_email method** +**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): @@ -133,9 +243,53 @@ Add this method to the `EmailService` class (after `send_account_invite_email`, return False ``` -**Step 2: Add the HTML renderer** +**Step 2: Add the send_feedback_confirmation_email method (user confirmation)** -Add this function at the bottom of the file (after the other `_render_*` functions): +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( @@ -199,18 +353,58 @@ def _render_feedback_html( """ + + +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 3: Commit** +**Step 4: Commit** ```bash git add backend/app/core/email.py -git commit -m "feat: add send_feedback_email to EmailService" +git commit -m "feat: add feedback notification and confirmation emails to EmailService" ``` --- -## Task 4: Backend Endpoint +## Task 5: Backend Endpoint **Files:** - Create: `backend/app/api/endpoints/feedback.py` @@ -235,12 +429,18 @@ 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") @@ -250,7 +450,7 @@ async def submit_feedback( current_user: Annotated[User, Depends(get_current_active_user)], db: Annotated[AsyncSession, Depends(get_db)], ): - """Submit user feedback via email.""" + """Submit user feedback. Saves to DB and sends notification email.""" if not settings.FEEDBACK_EMAIL: raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, @@ -269,6 +469,18 @@ async def submit_feedback( 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, @@ -280,10 +492,15 @@ async def submit_feedback( ) if not sent: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to send feedback. Please try again later.", - ) + 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.") ``` @@ -292,7 +509,7 @@ async def submit_feedback( In `backend/app/api/router.py`, add the import and include: -Add to imports (line 6, after the existing imports): +Add to imports (after the existing import lines): ```python from app.api.endpoints import feedback ``` @@ -306,17 +523,19 @@ api_router.include_router(feedback.router) ```bash git add backend/app/api/endpoints/feedback.py backend/app/api/router.py -git commit -m "feat: add POST /feedback endpoint" +git commit -m "feat: add POST /feedback endpoint with DB persistence and dual emails" ``` --- -## Task 5: Backend Test +## Task 6: Backend Tests **Files:** - Create: `backend/tests/test_feedback.py` -**Step 1: Write the test** +**Step 1: Write the tests** + +These tests use the project's existing `client` and `auth_headers` fixtures from `conftest.py`. ```python import pytest @@ -324,26 +543,22 @@ 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() - +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 async_client.post( + response = await client.post( "/api/v1/feedback", json={ - "email": "engineer@resolutionflow.example.com", + "email": "test@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}"}, + headers=auth_headers, ) assert response.status_code == 200 @@ -351,68 +566,98 @@ async def test_submit_feedback(async_client, engineer_token, monkeypatch): 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_not_configured(async_client, engineer_token): +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 async_client.post( + response = await client.post( "/api/v1/feedback", json={ - "email": "engineer@resolutionflow.example.com", + "email": "test@example.com", "feedback_type": "General Feedback", "message": "This is a general feedback message for testing.", }, - headers={"Authorization": f"Bearer {engineer_token}"}, + headers=auth_headers, ) assert response.status_code == 503 @pytest.mark.asyncio -async def test_submit_feedback_validation(async_client, engineer_token): +async def test_submit_feedback_validation_message_too_short(client, auth_headers): """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}"}, - ) + 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(async_client, engineer_token): +async def test_submit_feedback_invalid_type(client, auth_headers): """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}"}, - ) + 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(async_client): +async def test_submit_feedback_requires_auth(client): """Test that unauthenticated requests are rejected.""" - response = await async_client.post( + response = await client.post( "/api/v1/feedback", json={ "email": "anon@example.com", @@ -429,18 +674,18 @@ async def test_submit_feedback_requires_auth(async_client): cd backend && pytest tests/test_feedback.py -v --override-ini="addopts=" ``` -Expected: All 5 tests pass. If any fail, debug and fix before proceeding. +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" +git commit -m "test: add feedback endpoint tests including DB persistence" ``` --- -## Task 6: Frontend API Client +## Task 7: Frontend API Client **Files:** - Create: `frontend/src/api/feedback.ts` @@ -491,27 +736,33 @@ git commit -m "feat: add feedback API client" --- -## Task 7: Frontend Page +## 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 ` setFeedbackType(e.target.value)} - required - className={cn( - "w-full rounded-lg border border-border bg-card px-3 py-2 focus:border-primary focus:ring-1 focus:ring-primary/20 focus:outline-none", - feedbackType ? "text-foreground" : "text-muted-foreground" +
+ + {typeDropdownOpen && ( +
+ {FEEDBACK_TYPES.map(type => ( + + ))} +
)} - > - - {FEEDBACK_TYPES.map(type => ( - - ))} - +
{/* Message */} @@ -675,12 +950,12 @@ export default FeedbackPage ```bash git add frontend/src/pages/FeedbackPage.tsx -git commit -m "feat: add FeedbackPage component" +git commit -m "feat: add FeedbackPage with custom feedback type selector" ``` --- -## Task 8: Router & Navigation +## Task 9: Router & Navigation **Files:** - Modify: `frontend/src/router.tsx` @@ -760,7 +1035,7 @@ git commit -m "feat: add feedback route, sidebar nav item, and account link card --- -## Task 9: Build Verification +## Task 10: Build Verification **Step 1: Run the frontend build** @@ -776,7 +1051,7 @@ Expected: Build succeeds with no errors. TypeScript type-checking is stricter in cd backend && pytest tests/test_feedback.py -v --override-ini="addopts=" ``` -Expected: All tests pass. +Expected: All 6 tests pass. **Step 3: Set FEEDBACK_EMAIL in .env for local testing** @@ -792,13 +1067,15 @@ FEEDBACK_EMAIL=your-email@example.com 3. Log in as any test user 4. Navigate to `/feedback` via sidebar 5. Verify form loads with email pre-filled -6. Submit feedback — verify success state -7. Check email inbox for the formatted feedback email -8. Verify the account settings page shows the "Send Feedback" link card +6. Click the feedback type dropdown — verify descriptions appear under each option +7. Submit feedback — verify success state with confirmation email note +8. Check email inbox for: (a) admin notification email, (b) user confirmation email +9. Verify feedback row in DB: `docker exec -it patherly_postgres psql -U postgres -d patherly -c "SELECT * FROM feedback;"` +10. Verify the account settings page shows the "Send Feedback" link card --- -## Task 10: Final Commit & Cleanup +## Task 11: Final Verification **Step 1: Run full backend test suite**