docs: revise feedback implementation plan with enhancements
Adds: DB persistence, feedback type helper text, confirmation email to submitter, and TODO breadcrumbs for post-session prompt. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,11 +2,11 @@
|
|||||||
|
|
||||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
> **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`
|
**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: <auto-generated>
|
||||||
|
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 = '<auto-generated>'
|
||||||
|
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:**
|
**Files:**
|
||||||
- Modify: `backend/app/core/config.py`
|
- 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:**
|
**Files:**
|
||||||
- Modify: `backend/app/core/email.py`
|
- 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):
|
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
|
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
|
```python
|
||||||
def _render_feedback_html(
|
def _render_feedback_html(
|
||||||
@@ -199,18 +353,58 @@ def _render_feedback_html(
|
|||||||
</td></tr>
|
</td></tr>
|
||||||
</table>
|
</table>
|
||||||
</body></html>"""
|
</body></html>"""
|
||||||
|
|
||||||
|
|
||||||
|
def _render_feedback_confirmation_html(
|
||||||
|
feedback_type: str,
|
||||||
|
message_preview: str,
|
||||||
|
) -> str:
|
||||||
|
import html
|
||||||
|
|
||||||
|
safe_preview = html.escape(message_preview)
|
||||||
|
|
||||||
|
return f"""<!DOCTYPE html>
|
||||||
|
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"></head>
|
||||||
|
<body style="margin:0;padding:0;background:#000;font-family:'Inter',Helvetica,Arial,sans-serif;">
|
||||||
|
<table width="100%" cellpadding="0" cellspacing="0" style="background:#000;padding:40px 0;">
|
||||||
|
<tr><td align="center">
|
||||||
|
<table width="560" cellpadding="0" cellspacing="0" style="background:#111;border:1px solid rgba(255,255,255,0.06);border-radius:16px;">
|
||||||
|
<tr><td style="padding:40px 40px 24px;text-align:center;">
|
||||||
|
<h1 style="margin:0;color:#fff;font-size:24px;font-weight:600;">ResolutionFlow</h1>
|
||||||
|
<p style="margin:8px 0 0;color:#a0a0a0;font-size:14px;">Thanks for your feedback!</p>
|
||||||
|
</td></tr>
|
||||||
|
<tr><td style="padding:0 40px 24px;">
|
||||||
|
<p style="margin:0;color:#e0e0e0;font-size:16px;line-height:1.6;">
|
||||||
|
We've received your <strong style="color:#fff;">{html.escape(feedback_type)}</strong> and our team will review it shortly.
|
||||||
|
</p>
|
||||||
|
</td></tr>
|
||||||
|
<tr><td style="padding:0 40px 24px;">
|
||||||
|
<div style="background:#000;border:1px solid rgba(255,255,255,0.1);border-radius:12px;padding:16px 20px;">
|
||||||
|
<p style="margin:0 0 4px;color:#a0a0a0;font-size:12px;text-transform:uppercase;letter-spacing:1px;">Your feedback</p>
|
||||||
|
<p style="margin:0;color:#e0e0e0;font-size:14px;line-height:1.5;font-style:italic;">"{safe_preview}"</p>
|
||||||
|
</div>
|
||||||
|
</td></tr>
|
||||||
|
<tr><td style="padding:0 40px 32px;">
|
||||||
|
<p style="margin:0;color:#666;font-size:12px;text-align:center;">
|
||||||
|
If we need more details, we'll reach out to you directly.
|
||||||
|
</p>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</body></html>"""
|
||||||
```
|
```
|
||||||
|
|
||||||
**Step 3: Commit**
|
**Step 4: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add backend/app/core/email.py
|
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:**
|
**Files:**
|
||||||
- Create: `backend/app/api/endpoints/feedback.py`
|
- 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.core.rate_limit import limiter
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.models.account import Account
|
from app.models.account import Account
|
||||||
|
from app.models.feedback import Feedback
|
||||||
from app.schemas.feedback import FeedbackSubmission, FeedbackResponse
|
from app.schemas.feedback import FeedbackSubmission, FeedbackResponse
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(tags=["feedback"])
|
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)
|
@router.post("/feedback", response_model=FeedbackResponse)
|
||||||
@limiter.limit("1/minute")
|
@limiter.limit("1/minute")
|
||||||
@@ -250,7 +450,7 @@ async def submit_feedback(
|
|||||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||||
db: Annotated[AsyncSession, Depends(get_db)],
|
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:
|
if not settings.FEEDBACK_EMAIL:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
@@ -269,6 +469,18 @@ async def submit_feedback(
|
|||||||
account_name = account.name
|
account_name = account.name
|
||||||
account_code = account.display_code
|
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(
|
sent = await EmailService.send_feedback_email(
|
||||||
to_email=settings.FEEDBACK_EMAIL,
|
to_email=settings.FEEDBACK_EMAIL,
|
||||||
reply_to_email=data.email,
|
reply_to_email=data.email,
|
||||||
@@ -280,10 +492,15 @@ async def submit_feedback(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if not sent:
|
if not sent:
|
||||||
raise HTTPException(
|
logger.warning("Feedback saved to DB but notification email failed for user %s", current_user.email)
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail="Failed to send feedback. Please try again later.",
|
# 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.")
|
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:
|
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
|
```python
|
||||||
from app.api.endpoints import feedback
|
from app.api.endpoints import feedback
|
||||||
```
|
```
|
||||||
@@ -306,17 +523,19 @@ api_router.include_router(feedback.router)
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add backend/app/api/endpoints/feedback.py backend/app/api/router.py
|
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:**
|
**Files:**
|
||||||
- Create: `backend/tests/test_feedback.py`
|
- 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
|
```python
|
||||||
import pytest
|
import pytest
|
||||||
@@ -324,26 +543,22 @@ from unittest.mock import patch, AsyncMock
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_submit_feedback(async_client, engineer_token, monkeypatch):
|
async def test_submit_feedback(client, auth_headers):
|
||||||
"""Test successful feedback submission."""
|
"""Test successful feedback submission — saves to DB and sends emails."""
|
||||||
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, \
|
with patch("app.api.endpoints.feedback.settings") as mock_settings, \
|
||||||
patch("app.api.endpoints.feedback.EmailService") as mock_email:
|
patch("app.api.endpoints.feedback.EmailService") as mock_email:
|
||||||
mock_settings.FEEDBACK_EMAIL = "support@test.com"
|
mock_settings.FEEDBACK_EMAIL = "support@test.com"
|
||||||
mock_email.send_feedback_email = AsyncMock(return_value=True)
|
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",
|
"/api/v1/feedback",
|
||||||
json={
|
json={
|
||||||
"email": "engineer@resolutionflow.example.com",
|
"email": "test@example.com",
|
||||||
"feedback_type": "Bug Report",
|
"feedback_type": "Bug Report",
|
||||||
"message": "Something is broken in the tree editor when I try to save.",
|
"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
|
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 data["success"] is True
|
||||||
assert "submitted" in data["message"].lower()
|
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
|
@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."""
|
"""Test 503 when FEEDBACK_EMAIL is not set."""
|
||||||
with patch("app.api.endpoints.feedback.settings") as mock_settings:
|
with patch("app.api.endpoints.feedback.settings") as mock_settings:
|
||||||
mock_settings.FEEDBACK_EMAIL = None
|
mock_settings.FEEDBACK_EMAIL = None
|
||||||
|
|
||||||
response = await async_client.post(
|
response = await client.post(
|
||||||
"/api/v1/feedback",
|
"/api/v1/feedback",
|
||||||
json={
|
json={
|
||||||
"email": "engineer@resolutionflow.example.com",
|
"email": "test@example.com",
|
||||||
"feedback_type": "General Feedback",
|
"feedback_type": "General Feedback",
|
||||||
"message": "This is a general feedback message for testing.",
|
"message": "This is a general feedback message for testing.",
|
||||||
},
|
},
|
||||||
headers={"Authorization": f"Bearer {engineer_token}"},
|
headers=auth_headers,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 503
|
assert response.status_code == 503
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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."""
|
"""Test validation — message too short."""
|
||||||
with patch("app.api.endpoints.feedback.settings") as mock_settings:
|
response = await client.post(
|
||||||
mock_settings.FEEDBACK_EMAIL = "support@test.com"
|
"/api/v1/feedback",
|
||||||
|
json={
|
||||||
response = await async_client.post(
|
"email": "test@example.com",
|
||||||
"/api/v1/feedback",
|
"feedback_type": "Bug Report",
|
||||||
json={
|
"message": "short",
|
||||||
"email": "engineer@resolutionflow.example.com",
|
},
|
||||||
"feedback_type": "Bug Report",
|
headers=auth_headers,
|
||||||
"message": "short",
|
)
|
||||||
},
|
|
||||||
headers={"Authorization": f"Bearer {engineer_token}"},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 422
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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."""
|
"""Test validation — invalid feedback type."""
|
||||||
with patch("app.api.endpoints.feedback.settings") as mock_settings:
|
response = await client.post(
|
||||||
mock_settings.FEEDBACK_EMAIL = "support@test.com"
|
"/api/v1/feedback",
|
||||||
|
json={
|
||||||
response = await async_client.post(
|
"email": "test@example.com",
|
||||||
"/api/v1/feedback",
|
"feedback_type": "Invalid Type",
|
||||||
json={
|
"message": "This should fail because the type is invalid.",
|
||||||
"email": "engineer@resolutionflow.example.com",
|
},
|
||||||
"feedback_type": "Invalid Type",
|
headers=auth_headers,
|
||||||
"message": "This should fail because the type is invalid.",
|
)
|
||||||
},
|
|
||||||
headers={"Authorization": f"Bearer {engineer_token}"},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 422
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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."""
|
"""Test that unauthenticated requests are rejected."""
|
||||||
response = await async_client.post(
|
response = await client.post(
|
||||||
"/api/v1/feedback",
|
"/api/v1/feedback",
|
||||||
json={
|
json={
|
||||||
"email": "anon@example.com",
|
"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="
|
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**
|
**Step 3: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add backend/tests/test_feedback.py
|
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:**
|
**Files:**
|
||||||
- Create: `frontend/src/api/feedback.ts`
|
- 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:**
|
**Files:**
|
||||||
- Create: `frontend/src/pages/FeedbackPage.tsx`
|
- Create: `frontend/src/pages/FeedbackPage.tsx`
|
||||||
|
|
||||||
**Step 1: Create the page component**
|
**Step 1: Create the page component**
|
||||||
|
|
||||||
|
This version uses a custom feedback type selector with helper/description text instead of a plain `<select>`, so users can distinguish between types like "Usability Issue" vs "Bug Report."
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { MessageSquareText, Send, CheckCircle2 } from 'lucide-react'
|
import { MessageSquareText, Send, CheckCircle2, ChevronDown } from 'lucide-react'
|
||||||
import { useAuthStore } from '@/store/authStore'
|
import { useAuthStore } from '@/store/authStore'
|
||||||
import { feedbackApi } from '@/api'
|
import { feedbackApi } from '@/api'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { toast } from '@/lib/toast'
|
import { toast } from '@/lib/toast'
|
||||||
|
|
||||||
|
// TODO: Post-session contextual feedback prompt — after completing a troubleshooting
|
||||||
|
// session, show a subtle inline prompt like "How was this flow? Quick feedback →"
|
||||||
|
// that opens a lightweight version of this form pre-tagged with tree/session context.
|
||||||
|
|
||||||
const FEEDBACK_TYPES = [
|
const FEEDBACK_TYPES = [
|
||||||
'Bug Report',
|
{ value: 'Bug Report', description: 'Something is broken or not working as expected' },
|
||||||
'Feature Request',
|
{ value: 'Feature Request', description: "An idea for something new you'd like to see" },
|
||||||
'Usability Issue',
|
{ value: 'Usability Issue', description: 'Something works but is confusing or hard to use' },
|
||||||
'Documentation',
|
{ value: 'Documentation', description: 'Feedback on help docs, tooltips, or in-app guidance' },
|
||||||
'General Feedback',
|
{ value: 'General Feedback', description: 'Anything else — thoughts, impressions, suggestions' },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
export function FeedbackPage() {
|
export function FeedbackPage() {
|
||||||
@@ -522,9 +773,17 @@ export function FeedbackPage() {
|
|||||||
const [message, setMessage] = useState('')
|
const [message, setMessage] = useState('')
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
const [submitted, setSubmitted] = useState(false)
|
const [submitted, setSubmitted] = useState(false)
|
||||||
|
const [typeDropdownOpen, setTypeDropdownOpen] = useState(false)
|
||||||
|
|
||||||
const canSubmit = email.trim() && feedbackType && message.trim().length >= 10
|
const canSubmit = email.trim() && feedbackType && message.trim().length >= 10
|
||||||
|
|
||||||
|
const selectedType = FEEDBACK_TYPES.find(t => t.value === feedbackType)
|
||||||
|
|
||||||
|
const handleSelectType = (value: string) => {
|
||||||
|
setFeedbackType(value)
|
||||||
|
setTypeDropdownOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!canSubmit || isSubmitting) return
|
if (!canSubmit || isSubmitting) return
|
||||||
@@ -573,7 +832,7 @@ export function FeedbackPage() {
|
|||||||
<CheckCircle2 className="mx-auto h-12 w-12 text-green-500 mb-4" />
|
<CheckCircle2 className="mx-auto h-12 w-12 text-green-500 mb-4" />
|
||||||
<h2 className="text-xl font-semibold text-foreground mb-2">Thank you for your feedback!</h2>
|
<h2 className="text-xl font-semibold text-foreground mb-2">Thank you for your feedback!</h2>
|
||||||
<p className="text-muted-foreground mb-6">
|
<p className="text-muted-foreground mb-6">
|
||||||
We've received your submission and will review it shortly.
|
We've received your submission and will review it shortly. Check your email for a confirmation.
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={handleNewFeedback}
|
onClick={handleNewFeedback}
|
||||||
@@ -601,26 +860,42 @@ export function FeedbackPage() {
|
|||||||
<p className="mt-1 text-xs text-muted-foreground">We'll reply to this address if we need more details.</p>
|
<p className="mt-1 text-xs text-muted-foreground">We'll reply to this address if we need more details.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Feedback Type */}
|
{/* Feedback Type — custom selector with descriptions */}
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="feedback-type" className="block text-sm font-medium text-foreground mb-1.5">
|
<label className="block text-sm font-medium text-foreground mb-1.5">
|
||||||
Feedback Type
|
Feedback Type
|
||||||
</label>
|
</label>
|
||||||
<select
|
<div className="relative">
|
||||||
id="feedback-type"
|
<button
|
||||||
value={feedbackType}
|
type="button"
|
||||||
onChange={e => setFeedbackType(e.target.value)}
|
onClick={() => setTypeDropdownOpen(!typeDropdownOpen)}
|
||||||
required
|
className={cn(
|
||||||
className={cn(
|
"w-full rounded-lg border border-border bg-card px-3 py-2 text-left flex items-center justify-between focus:border-primary focus:ring-1 focus:ring-primary/20 focus:outline-none",
|
||||||
"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"
|
||||||
feedbackType ? "text-foreground" : "text-muted-foreground"
|
)}
|
||||||
|
>
|
||||||
|
<span>{selectedType?.value ?? 'Select a type...'}</span>
|
||||||
|
<ChevronDown size={16} className={cn("transition-transform", typeDropdownOpen && "rotate-180")} />
|
||||||
|
</button>
|
||||||
|
{typeDropdownOpen && (
|
||||||
|
<div className="absolute z-10 mt-1 w-full rounded-lg border border-border bg-card shadow-lg overflow-hidden">
|
||||||
|
{FEEDBACK_TYPES.map(type => (
|
||||||
|
<button
|
||||||
|
key={type.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSelectType(type.value)}
|
||||||
|
className={cn(
|
||||||
|
"w-full text-left px-3 py-2.5 hover:bg-accent transition-colors",
|
||||||
|
feedbackType === type.value && "bg-accent"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="text-sm font-medium text-foreground">{type.value}</div>
|
||||||
|
<div className="text-xs text-muted-foreground mt-0.5">{type.description}</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
>
|
</div>
|
||||||
<option value="" disabled>Select a type...</option>
|
|
||||||
{FEEDBACK_TYPES.map(type => (
|
|
||||||
<option key={type} value={type}>{type}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Message */}
|
{/* Message */}
|
||||||
@@ -675,12 +950,12 @@ export default FeedbackPage
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add frontend/src/pages/FeedbackPage.tsx
|
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:**
|
**Files:**
|
||||||
- Modify: `frontend/src/router.tsx`
|
- 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**
|
**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="
|
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**
|
**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
|
3. Log in as any test user
|
||||||
4. Navigate to `/feedback` via sidebar
|
4. Navigate to `/feedback` via sidebar
|
||||||
5. Verify form loads with email pre-filled
|
5. Verify form loads with email pre-filled
|
||||||
6. Submit feedback — verify success state
|
6. Click the feedback type dropdown — verify descriptions appear under each option
|
||||||
7. Check email inbox for the formatted feedback email
|
7. Submit feedback — verify success state with confirmation email note
|
||||||
8. Verify the account settings page shows the "Send Feedback" link card
|
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**
|
**Step 1: Run full backend test suite**
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user