diff --git a/backend/alembic/versions/e65b9f8fd458_add_feedback_table.py b/backend/alembic/versions/e65b9f8fd458_add_feedback_table.py new file mode 100644 index 00000000..75607beb --- /dev/null +++ b/backend/alembic/versions/e65b9f8fd458_add_feedback_table.py @@ -0,0 +1,42 @@ +"""add feedback table + +Revision ID: e65b9f8fd458 +Revises: 0fd2a90a9c2c +Create Date: 2026-02-18 17:39:16.939185 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + + +# revision identifiers, used by Alembic. +revision: str = 'e65b9f8fd458' +down_revision: Union[str, None] = '0fd2a90a9c2c' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = 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') diff --git a/backend/app/api/endpoints/feedback.py b/backend/app/api/endpoints/feedback.py new file mode 100644 index 00000000..a642071c --- /dev/null +++ b/backend/app/api/endpoints/feedback.py @@ -0,0 +1,88 @@ +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.") diff --git a/backend/app/api/router.py b/backend/app/api/router.py index 08580d80..3aac1d7f 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -4,6 +4,7 @@ from app.api.endpoints import admin_dashboard, admin_audit, admin_plan_limits, a from app.api.endpoints import ratings, analytics from app.api.endpoints import target_lists from app.api.endpoints import maintenance_schedules +from app.api.endpoints import feedback api_router = APIRouter() @@ -32,3 +33,4 @@ api_router.include_router(ratings.router) api_router.include_router(analytics.router) api_router.include_router(target_lists.router) api_router.include_router(maintenance_schedules.router) +api_router.include_router(feedback.router) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 4f43e6e4..adc81277 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -55,6 +55,7 @@ class Settings(BaseSettings): # Email (Resend) RESEND_API_KEY: Optional[str] = None FROM_EMAIL: str = "ResolutionFlow " + FEEDBACK_EMAIL: Optional[str] = None @property def email_enabled(self) -> bool: diff --git a/backend/app/core/email.py b/backend/app/core/email.py index 4ad3d92d..a5dc036a 100644 --- a/backend/app/core/email.py +++ b/backend/app/core/email.py @@ -163,6 +163,92 @@ class EmailService: logger.exception("Failed to send account invite email to %s", to_email) return False + @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 + + @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 + def _render_invite_html( code: str, @@ -334,3 +420,106 @@ def _render_password_reset_html(reset_url: str) -> str: """ + + +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. +

+
+
+""" diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index a3c6f276..5bd2f3e6 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -25,6 +25,7 @@ from .platform_setting import PlatformSetting from .user_pinned_tree import UserPinnedTree from .target_list import TargetList from .maintenance_schedule import MaintenanceSchedule +from .feedback import Feedback __all__ = [ "User", @@ -61,4 +62,5 @@ __all__ = [ "UserPinnedTree", "TargetList", "MaintenanceSchedule", + "Feedback", ] diff --git a/backend/app/models/feedback.py b/backend/app/models/feedback.py new file mode 100644 index 00000000..bfd50302 --- /dev/null +++ b/backend/app/models/feedback.py @@ -0,0 +1,19 @@ +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)) diff --git a/backend/app/schemas/feedback.py b/backend/app/schemas/feedback.py new file mode 100644 index 00000000..6c979c84 --- /dev/null +++ b/backend/app/schemas/feedback.py @@ -0,0 +1,21 @@ +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 diff --git a/backend/tests/test_feedback.py b/backend/tests/test_feedback.py new file mode 100644 index 00000000..5f00ea7d --- /dev/null +++ b/backend/tests/test_feedback.py @@ -0,0 +1,128 @@ +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 diff --git a/docs/plans/2026-02-18-feedback-form-design.md b/docs/plans/2026-02-18-feedback-form-design.md new file mode 100644 index 00000000..e61fccf9 --- /dev/null +++ b/docs/plans/2026-02-18-feedback-form-design.md @@ -0,0 +1,155 @@ +# Feedback Form — Design Document + +> **Date:** 2026-02-18 +> **Revised:** 2026-02-18 — added DB persistence, feedback type helper text, confirmation email, future TODO notes + +## Overview + +A feedback form page where logged-in users can submit bug reports, feature requests, and general feedback. Submissions are persisted to a `feedback` database table and emailed to a configurable address via the existing Resend infrastructure. A confirmation email is sent back to the submitter. + +## Feedback Types + +| Type | Helper Text | +|------|-------------| +| Bug Report | Something is broken or not working as expected | +| Feature Request | An idea for something new you'd like to see | +| Usability Issue | Something works but is confusing or hard to use | +| Documentation | Feedback on help docs, tooltips, or in-app guidance | +| General Feedback | Anything else — thoughts, impressions, suggestions | + +## Email Format + +### Admin Notification Email + +**Subject:** +``` +[ResolutionFlow Feedback] Bug Report — 2026-02-18 — ACC-7X3K +``` + +Where `ACC-7X3K` is the user's account display code. + +**Body:** +``` +Feedback Type: Bug Report +Submitted By: engineer@example.com +Account: Contoso IT Services (ACC-7X3K) +Date: February 18, 2026 + +--- + +User's written feedback text goes here... +``` + +Reply-to is set to the submitter's email for direct replies. + +### Confirmation Email (to submitter) + +**Subject:** `Thanks for your feedback — ResolutionFlow` + +**Body:** Brief thank you, echoes back the feedback type and a preview of their message (first ~100 chars). Dark-themed HTML matching existing email templates. + +Fire-and-forget: if this email fails, it's logged but doesn't affect the user's submission response. + +## Database + +### `feedback` table + +| Column | Type | Notes | +|--------|------|-------| +| `id` | UUID | PK | +| `account_id` | UUID | FK to `accounts`, nullable, SET NULL on delete | +| `user_id` | UUID | FK to `users`, SET NULL on delete | +| `email` | String(255) | The reply-to email submitted | +| `feedback_type` | String(50) | Enum value | +| `message` | Text | Full feedback text | +| `created_at` | DateTime(tz) | Timestamp | + +Indexes on `account_id`, `user_id`, `created_at`. + +No admin view or API read endpoints — queryable directly in the DB when needed. + +**Important:** DB write happens *before* email sending. Email failure does NOT prevent the feedback from being saved. + +## Frontend + +### Page + +`FeedbackPage.tsx` — form page inside the app shell. + +### Access Points + +- Sidebar nav item (icon + "Feedback" label) — visible to all roles +- Link card on `AccountSettingsPage` + +### Route + +`/feedback` — top-level app shell route (not nested under `/account`). + +### Form Fields + +| Field | Details | +|-------|---------| +| Email | Auto-filled from logged-in user, editable | +| Feedback Type | Custom dropdown with description text per option | +| Message | Textarea, required, min 10 chars | +| Submit | `bg-gradient-brand`, disabled while submitting | + +### UX Flow + +1. Form loads with email pre-filled +2. User opens feedback type dropdown — sees label + helper description for each option +3. User selects type, writes message, submits +4. Button shows loading state during submission +5. On success: success message with confirmation email note, form resets +6. On error: inline error, form stays populated for retry + +### Styling + +Standard page layout — `container mx-auto`, `bg-card border border-border rounded-xl` form card, `max-w-2xl` width. + +### API Client + +`feedbackApi.submit()` in a new `api/feedback.ts` module. + +## Backend + +### Endpoint + +`POST /feedback` in `endpoints/feedback.py` + +### Schema + +`FeedbackSubmission` in `schemas/feedback.py`: +- `email: EmailStr` — validated as email +- `feedback_type: FeedbackType` — enum-validated against the 5 types +- `message: str` — min 10 chars, max 5000 chars + +### Auth + +Requires `get_current_active_user`. Account display code pulled from user's account relationship server-side. + +### Config + +`FEEDBACK_EMAIL: Optional[str] = None` in `config.py`. Endpoint returns 503 if not configured. + +### Email Service + +Two new static methods on `EmailService`: +- `send_feedback_email()` — admin notification with reply-to +- `send_feedback_confirmation_email()` — thank-you to submitter (fire-and-forget) + +Both use existing Resend client and dark-themed HTML matching existing email templates. + +### Rate Limiting + +One submission per minute per user. + +## Future Consideration + +**Post-session contextual feedback prompt** — do NOT build now. TODO comments in `FeedbackPage.tsx` and `endpoints/feedback.py` serve as breadcrumbs. Concept: after completing a troubleshooting session, show a subtle inline prompt that opens a lightweight version of the feedback form pre-tagged with tree/session context. The feedback infrastructure (DB table, email service, API endpoint) built here should be directly reusable. + +## Not Included (YAGNI) + +- No admin view for feedback +- No file attachments +- No public (unauthenticated) access 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..b4bcaa37 --- /dev/null +++ b/docs/plans/2026-02-18-feedback-form-implementation.md @@ -0,0 +1,1094 @@ +# 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 */} +
+ +