Move completed plan docs to docs/plans/archive/. Add survey migration 046 and reference HTML/plan files. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
18 KiB
FlowPilot Survey — Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Build a public-facing survey page at /survey that collects FlowPilot research responses from senior MSP engineers and emails the results to Michael. The survey is unauthenticated (no login required), uses the existing ResolutionFlow design system, and has a backend endpoint to receive and email submissions via the existing Resend infrastructure.
Architecture: Static React page (public route, no auth) → POST /api/v1/survey/submit (unauthenticated, rate-limited) → saves to DB → sends notification email via existing EmailService + Resend. No confirmation email to respondent (they may not provide an email).
Design doc reference: The survey content and question structure is defined in the HTML file at frontend/public/survey-reference.html (copy the uploaded survey.html there for reference). The page must be rebuilt as a proper React component using the existing design system — NOT the inline-styled HTML.
Tech Stack: FastAPI backend, SQLAlchemy 2.0 (async), Alembic, Pydantic v2. React 19, TypeScript, Tailwind CSS, shadcn/ui frontend.
Reference File
Before starting, copy the survey HTML reference file into the project:
cp /path/to/survey.html docs/reference/flowpilot-survey-reference.html
This file contains the exact questions, options, types, and flow. Use it as the source of truth for survey content. The visual design should NOT copy the inline styles — instead, use the ResolutionFlow design system (Tailwind + shadcn/ui + brand tokens).
Phase 1: Backend
Task 1: Database Model — SurveyResponse
Files:
- Create:
backend/app/models/survey_response.py - Modify:
backend/app/models/__init__.py(add import +__all__entry)
Step 1: Create the model file
# backend/app/models/survey_response.py
"""FlowPilot survey response storage."""
import uuid
from datetime import datetime, timezone
from sqlalchemy import Column, DateTime, String, Text
from sqlalchemy.dialects.postgresql import JSONB, UUID
from app.core.database import Base
class SurveyResponse(Base):
__tablename__ = "survey_responses"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
respondent_name = Column(String(255), nullable=True) # Optional — we may already know who they are
responses = Column(JSONB, nullable=False) # Full survey answers as JSON
ip_address = Column(String(45), nullable=True) # For rate limiting / dedup
user_agent = Column(Text, nullable=True) # Browser info
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
Step 2: Add to models __init__.py
Add import and __all__ entry following the existing pattern in the file.
Step 3: Commit
git add backend/app/models/survey_response.py backend/app/models/__init__.py
git commit -m "feat: add SurveyResponse model for FlowPilot research"
Task 2: Database Migration
Files:
- Create: new Alembic migration
Step 1: Generate migration
cd backend
alembic revision --autogenerate -m "add survey_responses table"
Step 2: Review the generated migration — verify it creates survey_responses with the correct columns.
Step 3: Run migration
alembic upgrade head
Step 4: Commit
git add backend/alembic/versions/
git commit -m "feat: add survey_responses migration"
Task 3: Pydantic Schema
Files:
- Create:
backend/app/schemas/survey.py
Step 1: Create schema
# backend/app/schemas/survey.py
"""Schemas for FlowPilot survey submission."""
from typing import Any, Optional
from pydantic import BaseModel, Field
class SurveySubmission(BaseModel):
"""Incoming survey response from the public form."""
respondent_name: Optional[str] = Field(None, max_length=255)
responses: dict[str, Any] = Field(
...,
description="Question ID → answer mapping. Values can be strings, lists, or numbers.",
)
class SurveySubmissionResponse(BaseModel):
"""Response after successful submission."""
message: str = "Thank you for your response!"
id: str
Step 2: Commit
git add backend/app/schemas/survey.py
git commit -m "feat: add survey submission schemas"
Task 4: API Endpoint
Files:
- Create:
backend/app/api/endpoints/survey.py - Modify:
backend/app/api/router.py(register the new router)
Step 1: Create the endpoint
# backend/app/api/endpoints/survey.py
"""Public survey submission endpoint. No authentication required."""
import logging
from typing import Annotated
from fastapi import APIRouter, Depends, Request
from sqlalchemy.ext.asyncio import AsyncSession
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.survey_response import SurveyResponse
from app.schemas.survey import SurveySubmission, SurveySubmissionResponse
logger = logging.getLogger(__name__)
router = APIRouter(tags=["survey"])
@router.post("/survey/submit", response_model=SurveySubmissionResponse)
@limiter.limit("3/hour")
async def submit_survey(
request: Request,
data: SurveySubmission,
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Accept a public survey submission. No auth required.
Rate limited to 3/hour per IP to prevent spam.
Saves to DB and sends notification email to configured address.
"""
# Get client info
ip = request.client.host if request.client else None
ua = request.headers.get("user-agent", "")
# Save to database
response = SurveyResponse(
respondent_name=data.respondent_name,
responses=data.responses,
ip_address=ip,
user_agent=ua,
)
db.add(response)
await db.flush()
# Send notification email (fire-and-forget, don't fail the request)
try:
await EmailService.send_survey_notification_email(
to_email=settings.FEEDBACK_EMAIL, # Reuse existing feedback email config
respondent_name=data.respondent_name,
responses=data.responses,
)
except Exception:
logger.exception("Failed to send survey notification email")
return SurveySubmissionResponse(id=str(response.id))
Step 2: Register in router.py
Add to backend/app/api/router.py following the existing pattern. Import and include the survey router. IMPORTANT: This endpoint does NOT use get_current_active_user — it's public. Make sure the route is included without any auth dependency prefix.
The survey route should be registered similarly to how the auth routes are registered (no auth prefix), e.g.:
from app.api.endpoints import survey
api_router.include_router(survey.router, prefix="")
Step 3: Commit
git add backend/app/api/endpoints/survey.py backend/app/api/router.py
git commit -m "feat: add public survey submission endpoint with rate limiting"
Task 5: Email Notification
Files:
- Modify:
backend/app/core/email.py(add new static method toEmailService)
Step 1: Add send_survey_notification_email to EmailService
Follow the exact same pattern as send_feedback_email() — it's a notification to Michael with the response content. Use the same dark-themed HTML template style as existing emails.
@staticmethod
async def send_survey_notification_email(
to_email: str,
respondent_name: str | None,
responses: dict,
) -> bool:
"""Send survey response notification. Fire-and-forget."""
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
name_display = respondent_name or "Anonymous"
date_str = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
subject = f"[FlowPilot Survey] New Response from {name_display} — {date_str}"
# Build response summary as HTML
rows_html = ""
for q_id, answer in responses.items():
if isinstance(answer, list):
answer_display = "<br>".join(f"• {item}" for item in answer)
else:
answer_display = str(answer)
rows_html += f"""
<tr>
<td style="padding: 10px 14px; border-bottom: 1px solid #27272a; color: #818cf8; font-weight: 600; vertical-align: top; width: 120px; font-size: 13px;">{q_id}</td>
<td style="padding: 10px 14px; border-bottom: 1px solid #27272a; color: #e4e4e7; font-size: 14px; white-space: pre-wrap;">{answer_display}</td>
</tr>"""
html = f"""<!DOCTYPE html>
<html><body style="margin:0; padding:0; background-color:#09090b; font-family:Arial,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background-color:#09090b; padding:40px 20px;">
<tr><td align="center">
<table width="600" cellpadding="0" cellspacing="0" style="background-color:#18181b; border-radius:12px; border:1px solid #27272a;">
<tr><td style="padding:32px 32px 20px;">
<h1 style="margin:0 0 4px; font-size:20px; color:#f4f4f5;">FlowPilot Survey Response</h1>
<p style="margin:0; font-size:13px; color:#71717a;">From: {name_display} • {date_str}</p>
</td></tr>
<tr><td style="padding:0 32px 32px;">
<table width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #27272a; border-radius:8px; overflow:hidden;">
{rows_html}
</table>
</td></tr>
<tr><td style="padding:0 32px 24px;">
<p style="margin:0; font-size:12px; color:#52525b;">This response has been saved to the survey_responses table.</p>
</td></tr>
</table>
</td></tr>
</table>
</body></html>"""
resend.Emails.send({
"from": settings.FROM_EMAIL,
"to": [to_email],
"subject": subject,
"html": html,
})
logger.info("Survey notification sent for respondent: %s", name_display)
return True
except Exception:
logger.exception("Failed to send survey notification email")
return False
Step 2: Commit
git add backend/app/core/email.py
git commit -m "feat: add survey notification email to EmailService"
Task 6: CORS — Allow Public Survey Endpoint
Files:
- Verify:
backend/app/main.pyCORS configuration
The survey endpoint needs to accept POST requests from the frontend. Since both frontend and backend are on the same domain (resolutionflow.com / api.resolutionflow.com), this should already work with existing CORS config. Verify that the existing CORS middleware allows the frontend origin. No changes should be needed, but confirm.
Phase 2: Frontend
Task 7: Survey Page Component
Files:
- Create:
frontend/src/pages/SurveyPage.tsx
CRITICAL: This is a public page. It must NOT import or depend on authStore, ProtectedRoute, or any authenticated API client. It uses its own direct fetch or a standalone axios call to POST /api/v1/survey/submit.
Design requirements:
- Use the existing ResolutionFlow design system: Tailwind CSS classes, shadcn/ui components (
Card,Button,Label,Textarea,Slider,Badge,Progress) - Dark theme by default (the page won't inherit app shell's theme toggle — hardcode
darkclass on root or use dark-only styles) - Fonts: Plus Jakarta Sans for headings, Inter for body (already in
index.html) - Brand gradient:
bg-gradient-brandorfrom-[#818cf8] to-[#a78bfa] - Use
bg-background,bg-card,text-foreground,text-muted-foreground,border-bordertokens - Responsive (works on mobile — engineers might fill this out on their phone)
Component structure:
SurveyPage
├── SurveyHero (title, description, stats)
├── SurveyProgress (step dots + progress bar)
├── SurveySlide (rendered per active slide)
│ ├── MultipleChoice (single select)
│ ├── MultipleChoiceMulti (multi select)
│ ├── RangeSlider
│ ├── TextAnswer (textarea)
│ ├── DragRank (drag-to-reorder list)
│ └── ScenarioBox (ticket simulation display)
├── SlideNavigation (back/next buttons)
└── SurveyComplete (thank you + submit)
Question data: Extract the SLIDES array from the reference HTML file (docs/reference/flowpilot-survey-reference.html). Port it to a TypeScript constant with proper types:
type QuestionType = 'mc' | 'mc-multi' | 'range' | 'text' | 'rank';
interface SurveyQuestion {
id: string;
type: QuestionType;
num: string;
text: string;
hint?: string;
options?: string[]; // for mc, mc-multi
items?: string[]; // for rank
min?: number; // for range
max?: number;
step?: number;
suffix?: string;
low_label?: string;
high_label?: string;
}
interface SurveySlide {
id: string;
questions: SurveyQuestion[];
scenario?: {
title: string;
symptom: string;
details: string;
};
}
State management: Use local React state (useState / useReducer) — do NOT create a Zustand store for this. It's a self-contained page.
Submission flow:
- User clicks "Submit" on last slide
- Show loading state on button
- POST to
${VITE_API_URL}/api/v1/survey/submitwith{ respondent_name: null, responses: answersObject } - On success → show completion screen
- On error → show error toast, keep form populated for retry
- Also offer "Copy to Clipboard" as fallback if submission fails
The drag-and-drop ranking component:
- Use native HTML5 drag events (same approach as the reference HTML)
- Support touch events for mobile
- Show numbered positions that update on reorder
- Use
border-borderfor item borders,bg-accentfor drag-over state
Step 1: Create frontend/src/pages/SurveyPage.tsx
Build the complete page as a single file component (it's a standalone page, not reused elsewhere). Aim for ~400-600 lines. Use shadcn components where they fit (Button, Card, Progress, Textarea, Slider, Badge), custom Tailwind for the rest.
Step 2: Verify build
cd frontend && npm run build
Step 3: Commit
git add frontend/src/pages/SurveyPage.tsx
git commit -m "feat: add SurveyPage component for FlowPilot research"
Task 8: Public Route
Files:
- Modify:
frontend/src/router.tsx
Step 1: Add public route for /survey
Add as a top-level route (same level as /login, /register, /share/:shareToken — NOT inside the ProtectedRoute wrapper):
import SurveyPage from '@/pages/SurveyPage'
// ... in the router array, at the top level alongside login/register:
{
path: '/survey',
element: <SurveyPage />,
errorElement: <RouteError />,
},
IMPORTANT: This route must be OUTSIDE the ProtectedRoute parent. It must not require authentication.
Step 2: Verify build
cd frontend && npm run build
Step 3: Verify route works
Navigate to http://localhost:5173/survey — should render the survey without any login redirect.
Step 4: Commit
git add frontend/src/router.tsx
git commit -m "feat: add public /survey route"
Task 9: Nginx Catch-All Verification
Files:
- Verify:
frontend/nginx.conf
The existing nginx config should already have a try_files $uri $uri/ /index.html catch-all that sends unknown paths to the React router. Verify that /survey will be caught by this rule and served correctly. No changes should be needed.
Phase 3: Polish & Testing
Task 10: Backend Test
Files:
- Create:
backend/tests/test_survey.py
Write a test that:
- POSTs a valid survey submission to
/api/v1/survey/submit(no auth headers) - Verifies 200 response with
idfield - Verifies the response was saved to the database
- Tests rate limiting (4th request within an hour returns 429)
- Tests validation (empty
responsesdict returns 422)
Follow the existing test patterns in backend/tests/test_auth.py.
Step 1: Create test file
Step 2: Run tests
cd backend && pytest tests/test_survey.py -v
Step 3: Commit
git add backend/tests/test_survey.py
git commit -m "test: add survey submission endpoint tests"
Task 11: Mobile Responsiveness Check
Verify the survey page renders correctly at these breakpoints:
- Desktop: 1024px+
- Tablet: 768px
- Mobile: 375px
The drag-and-drop ranking must work on touch devices. If shadcn's Slider component doesn't render well on mobile, use a custom range input with Tailwind styling.
Summary
| Phase | Tasks | New Files | Modified Files |
|---|---|---|---|
| Backend | Model, migration, schema, endpoint, email | models/survey_response.py, schemas/survey.py, api/endpoints/survey.py, migration |
models/__init__.py, api/router.py, core/email.py |
| Frontend | Survey page, public route | pages/SurveyPage.tsx |
router.tsx |
| Testing | Backend test, mobile check | tests/test_survey.py |
— |
Total estimated effort: 3-4 hours for Claude Code execution.
Key constraints:
/surveyroute is PUBLIC — no authentication- Rate limit: 3 submissions per hour per IP
- Email notification uses existing Resend +
FEEDBACK_EMAILconfig - Design uses existing Tailwind/shadcn design system, NOT the inline CSS from the reference HTML
- Must work on mobile (engineers may fill this out on their phone)
- The reference HTML at
docs/reference/flowpilot-survey-reference.htmlis the source of truth for question content