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>
This commit is contained in:
536
docs/plans/survey-implementation-plan.md
Normal file
536
docs/plans/survey-implementation-plan.md
Normal file
@@ -0,0 +1,536 @@
|
||||
# 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:
|
||||
|
||||
```bash
|
||||
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**
|
||||
|
||||
```python
|
||||
# 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**
|
||||
|
||||
```bash
|
||||
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**
|
||||
|
||||
```bash
|
||||
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**
|
||||
|
||||
```bash
|
||||
alembic upgrade head
|
||||
```
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
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**
|
||||
|
||||
```python
|
||||
# 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**
|
||||
|
||||
```bash
|
||||
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**
|
||||
|
||||
```python
|
||||
# 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.:
|
||||
```python
|
||||
from app.api.endpoints import survey
|
||||
api_router.include_router(survey.router, prefix="")
|
||||
```
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
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.
|
||||
|
||||
```python
|
||||
@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**
|
||||
|
||||
```bash
|
||||
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:
|
||||
|
||||
```typescript
|
||||
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**
|
||||
|
||||
```bash
|
||||
cd frontend && npm run build
|
||||
```
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
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):
|
||||
|
||||
```typescript
|
||||
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**
|
||||
|
||||
```bash
|
||||
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**
|
||||
|
||||
```bash
|
||||
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**
|
||||
|
||||
```bash
|
||||
cd backend && pytest tests/test_survey.py -v
|
||||
```
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
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
|
||||
Reference in New Issue
Block a user