Files
resolutionflow/docs/plans/survey-implementation-plan.md
chihlasm 932927b9df chore: archive old plan docs + add survey foundation files
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>
2026-03-05 02:03:38 -05:00

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 to EmailService)

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} &bull; {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.py CORS 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 dark class on root or use dark-only styles)
  • Fonts: Plus Jakarta Sans for headings, Inter for body (already in index.html)
  • Brand gradient: bg-gradient-brand or from-[#818cf8] to-[#a78bfa]
  • Use bg-background, bg-card, text-foreground, text-muted-foreground, border-border tokens
  • 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:

  1. User clicks "Submit" on last slide
  2. Show loading state on button
  3. POST to ${VITE_API_URL}/api/v1/survey/submit with { respondent_name: null, responses: answersObject }
  4. On success → show completion screen
  5. On error → show error toast, keep form populated for retry
  6. 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-border for item borders, bg-accent for 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:

  1. POSTs a valid survey submission to /api/v1/survey/submit (no auth headers)
  2. Verifies 200 response with id field
  3. Verifies the response was saved to the database
  4. Tests rate limiting (4th request within an hour returns 429)
  5. Tests validation (empty responses dict 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:

  • /survey route is PUBLIC — no authentication
  • Rate limit: 3 submissions per hour per IP
  • Email notification uses existing Resend + FEEDBACK_EMAIL config
  • 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.html is the source of truth for question content