feat: add user feedback form with DB persistence and email notifications #81
42
backend/alembic/versions/e65b9f8fd458_add_feedback_table.py
Normal file
42
backend/alembic/versions/e65b9f8fd458_add_feedback_table.py
Normal file
@@ -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')
|
||||||
88
backend/app/api/endpoints/feedback.py
Normal file
88
backend/app/api/endpoints/feedback.py
Normal file
@@ -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.")
|
||||||
@@ -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 ratings, analytics
|
||||||
from app.api.endpoints import target_lists
|
from app.api.endpoints import target_lists
|
||||||
from app.api.endpoints import maintenance_schedules
|
from app.api.endpoints import maintenance_schedules
|
||||||
|
from app.api.endpoints import feedback
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
|
|
||||||
@@ -32,3 +33,4 @@ api_router.include_router(ratings.router)
|
|||||||
api_router.include_router(analytics.router)
|
api_router.include_router(analytics.router)
|
||||||
api_router.include_router(target_lists.router)
|
api_router.include_router(target_lists.router)
|
||||||
api_router.include_router(maintenance_schedules.router)
|
api_router.include_router(maintenance_schedules.router)
|
||||||
|
api_router.include_router(feedback.router)
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ class Settings(BaseSettings):
|
|||||||
# Email (Resend)
|
# Email (Resend)
|
||||||
RESEND_API_KEY: Optional[str] = None
|
RESEND_API_KEY: Optional[str] = None
|
||||||
FROM_EMAIL: str = "ResolutionFlow <invites@resolutionflow.com>"
|
FROM_EMAIL: str = "ResolutionFlow <invites@resolutionflow.com>"
|
||||||
|
FEEDBACK_EMAIL: Optional[str] = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def email_enabled(self) -> bool:
|
def email_enabled(self) -> bool:
|
||||||
|
|||||||
@@ -163,6 +163,92 @@ class EmailService:
|
|||||||
logger.exception("Failed to send account invite email to %s", to_email)
|
logger.exception("Failed to send account invite email to %s", to_email)
|
||||||
return False
|
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(
|
def _render_invite_html(
|
||||||
code: str,
|
code: str,
|
||||||
@@ -334,3 +420,106 @@ def _render_password_reset_html(reset_url: str) -> str:
|
|||||||
</td></tr>
|
</td></tr>
|
||||||
</table>
|
</table>
|
||||||
</body></html>"""
|
</body></html>"""
|
||||||
|
|
||||||
|
|
||||||
|
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", "<br>")
|
||||||
|
|
||||||
|
account_line = ""
|
||||||
|
if account_name and account_code:
|
||||||
|
account_line = f"""
|
||||||
|
<tr><td style="padding:0 40px 8px;">
|
||||||
|
<p style="margin:0;color:#a0a0a0;font-size:14px;">
|
||||||
|
<strong style="color:#e0e0e0;">Account:</strong> {html.escape(account_name)} ({html.escape(account_code)})
|
||||||
|
</p>
|
||||||
|
</td></tr>"""
|
||||||
|
|
||||||
|
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 Feedback</h1>
|
||||||
|
</td></tr>
|
||||||
|
<tr><td style="padding:0 40px 8px;">
|
||||||
|
<p style="margin:0;color:#a0a0a0;font-size:14px;">
|
||||||
|
<strong style="color:#e0e0e0;">Type:</strong> {html.escape(feedback_type)}
|
||||||
|
</p>
|
||||||
|
</td></tr>
|
||||||
|
<tr><td style="padding:0 40px 8px;">
|
||||||
|
<p style="margin:0;color:#a0a0a0;font-size:14px;">
|
||||||
|
<strong style="color:#e0e0e0;">From:</strong> {html.escape(user_email)}
|
||||||
|
</p>
|
||||||
|
</td></tr>
|
||||||
|
{account_line}
|
||||||
|
<tr><td style="padding:0 40px 8px;">
|
||||||
|
<p style="margin:0;color:#a0a0a0;font-size:14px;">
|
||||||
|
<strong style="color:#e0e0e0;">Date:</strong> {date_str}
|
||||||
|
</p>
|
||||||
|
</td></tr>
|
||||||
|
<tr><td style="padding:16px 40px 0;">
|
||||||
|
<div style="border-top:1px solid rgba(255,255,255,0.06);padding-top:16px;">
|
||||||
|
<p style="margin:0;color:#e0e0e0;font-size:15px;line-height:1.7;">{safe_message}</p>
|
||||||
|
</div>
|
||||||
|
</td></tr>
|
||||||
|
<tr><td style="padding:24px 40px 32px;">
|
||||||
|
<p style="margin:0;color:#666;font-size:12px;text-align:center;">
|
||||||
|
Reply directly to this email to respond to the user.
|
||||||
|
</p>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</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>"""
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ from .platform_setting import PlatformSetting
|
|||||||
from .user_pinned_tree import UserPinnedTree
|
from .user_pinned_tree import UserPinnedTree
|
||||||
from .target_list import TargetList
|
from .target_list import TargetList
|
||||||
from .maintenance_schedule import MaintenanceSchedule
|
from .maintenance_schedule import MaintenanceSchedule
|
||||||
|
from .feedback import Feedback
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"User",
|
"User",
|
||||||
@@ -61,4 +62,5 @@ __all__ = [
|
|||||||
"UserPinnedTree",
|
"UserPinnedTree",
|
||||||
"TargetList",
|
"TargetList",
|
||||||
"MaintenanceSchedule",
|
"MaintenanceSchedule",
|
||||||
|
"Feedback",
|
||||||
]
|
]
|
||||||
|
|||||||
19
backend/app/models/feedback.py
Normal file
19
backend/app/models/feedback.py
Normal file
@@ -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))
|
||||||
21
backend/app/schemas/feedback.py
Normal file
21
backend/app/schemas/feedback.py
Normal file
@@ -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
|
||||||
128
backend/tests/test_feedback.py
Normal file
128
backend/tests/test_feedback.py
Normal file
@@ -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
|
||||||
155
docs/plans/2026-02-18-feedback-form-design.md
Normal file
155
docs/plans/2026-02-18-feedback-form-design.md
Normal file
@@ -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
|
||||||
1094
docs/plans/2026-02-18-feedback-form-implementation.md
Normal file
1094
docs/plans/2026-02-18-feedback-form-implementation.md
Normal file
File diff suppressed because it is too large
Load Diff
1327
docs/plans/2026-02-18-flow-editor-ux-impl.md
Normal file
1327
docs/plans/2026-02-18-flow-editor-ux-impl.md
Normal file
File diff suppressed because it is too large
Load Diff
21
frontend/src/api/feedback.ts
Normal file
21
frontend/src/api/feedback.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
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<FeedbackResponse> => {
|
||||||
|
const { data: response } = await apiClient.post('/feedback', data)
|
||||||
|
return response
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default feedbackApi
|
||||||
@@ -15,3 +15,4 @@ export { default as pinnedFlowsApi } from './pinnedFlows'
|
|||||||
export { default as analyticsApi } from './analytics'
|
export { default as analyticsApi } from './analytics'
|
||||||
export { targetListsApi } from './targetLists'
|
export { targetListsApi } from './targetLists'
|
||||||
export { maintenanceSchedulesApi, batchLaunchApi } from './maintenanceSchedules'
|
export { maintenanceSchedulesApi, batchLaunchApi } from './maintenanceSchedules'
|
||||||
|
export { default as feedbackApi } from './feedback'
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useNavigate, useLocation } from 'react-router-dom'
|
import { useNavigate, useLocation } from 'react-router-dom'
|
||||||
import { LayoutGrid, Box, PenLine, Clock, FileText, Bookmark, BarChart3, Users, Settings, PanelLeftClose, PanelLeftOpen } from 'lucide-react'
|
import { LayoutGrid, Box, PenLine, Clock, FileText, Bookmark, BarChart3, Users, Settings, PanelLeftClose, PanelLeftOpen, MessageSquareText } from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
||||||
import { CategoryList } from '@/components/sidebar/CategoryList'
|
import { CategoryList } from '@/components/sidebar/CategoryList'
|
||||||
@@ -144,6 +144,7 @@ export function Sidebar() {
|
|||||||
<NavItem href="/shares" icon={FileText} label="Exports" collapsed />
|
<NavItem href="/shares" icon={FileText} label="Exports" collapsed />
|
||||||
<NavItem href="/step-library" icon={Bookmark} label="Step Library" collapsed />
|
<NavItem href="/step-library" icon={Bookmark} label="Step Library" collapsed />
|
||||||
<NavItem href="/analytics" icon={BarChart3} label="Analytics" collapsed />
|
<NavItem href="/analytics" icon={BarChart3} label="Analytics" collapsed />
|
||||||
|
<NavItem href="/feedback" icon={MessageSquareText} label="Feedback" collapsed />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -201,6 +202,7 @@ export function Sidebar() {
|
|||||||
)}>
|
)}>
|
||||||
{!sidebarCollapsed && (
|
{!sidebarCollapsed && (
|
||||||
<>
|
<>
|
||||||
|
<NavItem href="/feedback" icon={MessageSquareText} label="Feedback" />
|
||||||
<NavItem href="/account" icon={Users} label="Team" />
|
<NavItem href="/account" icon={Users} label="Team" />
|
||||||
<NavItem href="/account" icon={Settings} label="Settings" />
|
<NavItem href="/account" icon={Settings} label="Settings" />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { Building2, Users, Mail, Crown, Loader2, AlertCircle, Check, X, Settings, FolderTree, Server, RefreshCw } from 'lucide-react'
|
import { Building2, Users, Mail, Crown, Loader2, AlertCircle, Check, X, Settings, FolderTree, Server, RefreshCw, MessageSquareText } from 'lucide-react'
|
||||||
import { accountsApi } from '@/api/accounts'
|
import { accountsApi } from '@/api/accounts'
|
||||||
import type { Account, AccountMember, AccountInvite } from '@/types'
|
import type { Account, AccountMember, AccountInvite } from '@/types'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
@@ -513,6 +513,21 @@ export function AccountSettingsPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Feedback Link (all users) */}
|
||||||
|
<Link
|
||||||
|
to="/feedback"
|
||||||
|
className="bg-card border border-border rounded-xl p-4 sm:p-6 flex items-center justify-between group hover:border-border transition-all"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<MessageSquareText className="h-5 w-5 text-muted-foreground" />
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-foreground">Send Feedback</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">Report bugs, request features, or share your thoughts</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-muted-foreground group-hover:text-foreground transition-colors">→</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
{/* Preferences Section */}
|
{/* Preferences Section */}
|
||||||
<div className="bg-card border border-border rounded-xl p-4 sm:p-6">
|
<div className="bg-card border border-border rounded-xl p-4 sm:p-6">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
279
frontend/src/pages/FeedbackPage.tsx
Normal file
279
frontend/src/pages/FeedbackPage.tsx
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
import { useState, useRef, useEffect } from 'react'
|
||||||
|
import { MessageSquareText, Send, CheckCircle2, ChevronDown } from 'lucide-react'
|
||||||
|
import { useAuthStore } from '@/store/authStore'
|
||||||
|
import { feedbackApi } from '@/api'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
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 = [
|
||||||
|
{ value: 'Bug Report', description: 'Something is broken or not working as expected' },
|
||||||
|
{ value: 'Feature Request', description: "An idea for something new you'd like to see" },
|
||||||
|
{ value: 'Usability Issue', description: 'Something works but is confusing or hard to use' },
|
||||||
|
{ value: 'Documentation', description: 'Feedback on help docs, tooltips, or in-app guidance' },
|
||||||
|
{ value: 'General Feedback', description: 'Anything else — thoughts, impressions, suggestions' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export function FeedbackPage() {
|
||||||
|
const user = useAuthStore(s => s.user)
|
||||||
|
|
||||||
|
const [email, setEmail] = useState(user?.email ?? '')
|
||||||
|
const [feedbackType, setFeedbackType] = useState<string>('')
|
||||||
|
const [message, setMessage] = useState('')
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
const [submitted, setSubmitted] = useState(false)
|
||||||
|
const [typeDropdownOpen, setTypeDropdownOpen] = useState(false)
|
||||||
|
const [highlightedIndex, setHighlightedIndex] = useState(-1)
|
||||||
|
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||||
|
const listboxRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
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)
|
||||||
|
setHighlightedIndex(-1)
|
||||||
|
triggerRef.current?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDropdownKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
switch (e.key) {
|
||||||
|
case 'ArrowDown':
|
||||||
|
e.preventDefault()
|
||||||
|
if (!typeDropdownOpen) {
|
||||||
|
setTypeDropdownOpen(true)
|
||||||
|
setHighlightedIndex(0)
|
||||||
|
} else {
|
||||||
|
setHighlightedIndex(i => (i + 1) % FEEDBACK_TYPES.length)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'ArrowUp':
|
||||||
|
e.preventDefault()
|
||||||
|
if (!typeDropdownOpen) {
|
||||||
|
setTypeDropdownOpen(true)
|
||||||
|
setHighlightedIndex(FEEDBACK_TYPES.length - 1)
|
||||||
|
} else {
|
||||||
|
setHighlightedIndex(i => (i - 1 + FEEDBACK_TYPES.length) % FEEDBACK_TYPES.length)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'Enter':
|
||||||
|
case ' ':
|
||||||
|
e.preventDefault()
|
||||||
|
if (typeDropdownOpen && highlightedIndex >= 0) {
|
||||||
|
handleSelectType(FEEDBACK_TYPES[highlightedIndex].value)
|
||||||
|
} else {
|
||||||
|
setTypeDropdownOpen(true)
|
||||||
|
setHighlightedIndex(0)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'Escape':
|
||||||
|
e.preventDefault()
|
||||||
|
setTypeDropdownOpen(false)
|
||||||
|
setHighlightedIndex(-1)
|
||||||
|
triggerRef.current?.focus()
|
||||||
|
break
|
||||||
|
case 'Tab':
|
||||||
|
if (typeDropdownOpen && highlightedIndex >= 0) {
|
||||||
|
handleSelectType(FEEDBACK_TYPES[highlightedIndex].value)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!typeDropdownOpen) return
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
if (
|
||||||
|
triggerRef.current && !triggerRef.current.contains(e.target as Node) &&
|
||||||
|
listboxRef.current && !listboxRef.current.contains(e.target as Node)
|
||||||
|
) {
|
||||||
|
setTypeDropdownOpen(false)
|
||||||
|
setHighlightedIndex(-1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||||
|
}, [typeDropdownOpen])
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!canSubmit || isSubmitting) return
|
||||||
|
|
||||||
|
setIsSubmitting(true)
|
||||||
|
try {
|
||||||
|
const response = await feedbackApi.submit({
|
||||||
|
email: email.trim(),
|
||||||
|
feedback_type: feedbackType,
|
||||||
|
message: message.trim(),
|
||||||
|
})
|
||||||
|
if (response.success) {
|
||||||
|
setSubmitted(true)
|
||||||
|
setFeedbackType('')
|
||||||
|
setMessage('')
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const error = err as { response?: { data?: { detail?: string } } }
|
||||||
|
toast.error(error.response?.data?.detail || 'Failed to submit feedback. Please try again.')
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleNewFeedback = () => {
|
||||||
|
setSubmitted(false)
|
||||||
|
setEmail(user?.email ?? '')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
|
||||||
|
{/* Page header */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<MessageSquareText className="h-8 w-8 text-muted-foreground" />
|
||||||
|
<h1 className="text-2xl font-bold text-foreground sm:text-3xl">Send Feedback</h1>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-muted-foreground">
|
||||||
|
Help us improve ResolutionFlow. Report bugs, request features, or share your thoughts.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-2xl">
|
||||||
|
{submitted ? (
|
||||||
|
<div className="bg-card border border-border rounded-xl p-8 text-center">
|
||||||
|
<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>
|
||||||
|
<p className="text-muted-foreground mb-6">
|
||||||
|
We've received your submission and will review it shortly. Check your email for a confirmation.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={handleNewFeedback}
|
||||||
|
className="bg-gradient-brand text-white font-medium px-6 py-2.5 rounded-lg shadow-lg shadow-primary/20 hover:opacity-90 transition-opacity"
|
||||||
|
>
|
||||||
|
Send More Feedback
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleSubmit} className="bg-card border border-border rounded-xl p-4 sm:p-6 space-y-5">
|
||||||
|
{/* Email */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="feedback-email" className="block text-sm font-medium text-foreground mb-1.5">
|
||||||
|
Email Address
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="feedback-email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={e => 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"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">We'll reply to this address if we need more details.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Feedback Type — custom selector with descriptions */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-1.5">
|
||||||
|
Feedback Type
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
ref={triggerRef}
|
||||||
|
type="button"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={typeDropdownOpen}
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
aria-controls="feedback-type-listbox"
|
||||||
|
aria-activedescendant={highlightedIndex >= 0 ? `feedback-type-${highlightedIndex}` : undefined}
|
||||||
|
onClick={() => setTypeDropdownOpen(!typeDropdownOpen)}
|
||||||
|
onKeyDown={handleDropdownKeyDown}
|
||||||
|
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",
|
||||||
|
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
|
||||||
|
ref={listboxRef}
|
||||||
|
id="feedback-type-listbox"
|
||||||
|
role="listbox"
|
||||||
|
className="absolute z-10 mt-1 w-full rounded-lg border border-border bg-card shadow-lg overflow-hidden"
|
||||||
|
>
|
||||||
|
{FEEDBACK_TYPES.map((type, index) => (
|
||||||
|
<button
|
||||||
|
key={type.value}
|
||||||
|
id={`feedback-type-${index}`}
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={feedbackType === type.value}
|
||||||
|
onClick={() => handleSelectType(type.value)}
|
||||||
|
onMouseEnter={() => setHighlightedIndex(index)}
|
||||||
|
className={cn(
|
||||||
|
"w-full text-left px-3 py-2.5 hover:bg-accent transition-colors",
|
||||||
|
feedbackType === type.value && "bg-accent",
|
||||||
|
highlightedIndex === index && feedbackType !== type.value && "bg-accent/50"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Message */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="feedback-message" className="block text-sm font-medium text-foreground mb-1.5">
|
||||||
|
Your Feedback
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="feedback-message"
|
||||||
|
value={message}
|
||||||
|
onChange={e => setMessage(e.target.value)}
|
||||||
|
placeholder="Describe your feedback in detail..."
|
||||||
|
required
|
||||||
|
minLength={10}
|
||||||
|
rows={6}
|
||||||
|
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 resize-y"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{message.trim().length < 10
|
||||||
|
? `Minimum 10 characters (${message.trim().length}/10)`
|
||||||
|
: `${message.trim().length} characters`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Submit */}
|
||||||
|
<div className="pt-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!canSubmit || isSubmitting}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 rounded-lg px-6 py-2.5 font-medium text-white shadow-lg shadow-primary/20 transition-opacity",
|
||||||
|
canSubmit && !isSubmitting
|
||||||
|
? "bg-gradient-brand hover:opacity-90"
|
||||||
|
: "bg-gradient-brand opacity-50 cursor-not-allowed"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Send size={16} />
|
||||||
|
{isSubmitting ? 'Sending...' : 'Submit Feedback'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FeedbackPage
|
||||||
@@ -30,6 +30,7 @@ const SessionDetailPage = lazy(() => import('@/pages/SessionDetailPage'))
|
|||||||
const MySharesPage = lazy(() => import('@/pages/MySharesPage'))
|
const MySharesPage = lazy(() => import('@/pages/MySharesPage'))
|
||||||
const TeamAnalyticsPage = lazy(() => import('@/pages/TeamAnalyticsPage'))
|
const TeamAnalyticsPage = lazy(() => import('@/pages/TeamAnalyticsPage'))
|
||||||
const MyAnalyticsPage = lazy(() => import('@/pages/MyAnalyticsPage'))
|
const MyAnalyticsPage = lazy(() => import('@/pages/MyAnalyticsPage'))
|
||||||
|
const FeedbackPage = lazy(() => import('@/pages/FeedbackPage'))
|
||||||
const AccountSettingsPage = lazy(() => import('@/pages/AccountSettingsPage'))
|
const AccountSettingsPage = lazy(() => import('@/pages/AccountSettingsPage'))
|
||||||
// Admin pages
|
// Admin pages
|
||||||
const AdminLayout = lazy(() => import('@/components/admin/AdminLayout'))
|
const AdminLayout = lazy(() => import('@/components/admin/AdminLayout'))
|
||||||
@@ -226,6 +227,14 @@ export const router = createBrowserRouter([
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'feedback',
|
||||||
|
element: (
|
||||||
|
<Suspense fallback={<PageLoader />}>
|
||||||
|
<FeedbackPage />
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
|
},
|
||||||
// Admin routes
|
// Admin routes
|
||||||
{
|
{
|
||||||
path: 'admin',
|
path: 'admin',
|
||||||
|
|||||||
Reference in New Issue
Block a user