25 KiB
Survey Invite Tracking — Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Add invite tracking to the FlowPilot survey so the admin can create personalized single-use links, optionally email them, and see who has/hasn't responded.
Architecture: New survey_invites table with token-based tracking. survey_responses gets an invite_id FK. Public endpoint checks token status; submit endpoint validates and marks tokens as used. Admin CRUD endpoints behind require_admin. Frontend: admin page for creating/viewing invites, survey page reads ?t=<token> and gates on completion.
Tech Stack: FastAPI backend, SQLAlchemy 2.0 (async), Alembic, Pydantic v2. React 19, TypeScript, Tailwind CSS frontend. Existing EmailService + Resend for invite emails.
Design doc: docs/plans/2026-03-04-survey-invite-tracking-design.md
Phase 1: Backend
Task 1: SurveyInvite Model
Files:
- Create:
backend/app/models/survey_invite.py - Modify:
backend/app/models/__init__.py - Modify:
backend/alembic/env.py
Step 1: Create the model file
# backend/app/models/survey_invite.py
"""Survey invite tracking for FlowPilot research."""
import secrets
import uuid
from datetime import datetime, timezone
from sqlalchemy import Boolean, Column, DateTime, String
from sqlalchemy.dialects.postgresql import UUID
from app.core.database import Base
def _generate_token() -> str:
return secrets.token_urlsafe(16)
class SurveyInvite(Base):
__tablename__ = "survey_invites"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
token = Column(String(32), unique=True, nullable=False, default=_generate_token)
recipient_name = Column(String(255), nullable=False)
recipient_email = Column(String(255), nullable=True)
status = Column(String(20), nullable=False, default="pending")
email_sent = Column(Boolean, nullable=False, default=False)
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
completed_at = Column(DateTime(timezone=True), nullable=True)
Step 2: Add to models __init__.py
Add import and __all__ entry after SurveyResponse:
from .survey_invite import SurveyInvite
And in __all__:
"SurveyInvite",
Step 3: Add import to backend/alembic/env.py
After the SurveyResponse import line:
from app.models.survey_invite import SurveyInvite
Step 4: Commit
git add backend/app/models/survey_invite.py backend/app/models/__init__.py backend/alembic/env.py
git commit -m "feat: add SurveyInvite model"
Task 2: Migration — survey_invites table + invite_id FK on survey_responses
Files:
- Create:
backend/alembic/versions/047_add_survey_invites_table.py
Step 1: Create migration manually
"""Add survey_invites table and invite_id FK on survey_responses.
Revision ID: 047
Revises: 046
Create Date: 2026-03-04
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID
revision: str = "047"
down_revision: str = "046"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"survey_invites",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column("token", sa.String(32), unique=True, nullable=False),
sa.Column("recipient_name", sa.String(255), nullable=False),
sa.Column("recipient_email", sa.String(255), nullable=True),
sa.Column("status", sa.String(20), nullable=False, server_default="pending"),
sa.Column("email_sent", sa.Boolean, nullable=False, server_default="false"),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
)
op.add_column(
"survey_responses",
sa.Column("invite_id", UUID(as_uuid=True), sa.ForeignKey("survey_invites.id"), nullable=True),
)
def downgrade() -> None:
op.drop_column("survey_responses", "invite_id")
op.drop_table("survey_invites")
Step 2: Add invite_id column to SurveyResponse model
In backend/app/models/survey_response.py, add after user_agent:
from sqlalchemy import Column, DateTime, ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import JSONB, UUID
# ... existing columns ...
invite_id = Column(UUID(as_uuid=True), ForeignKey("survey_invites.id"), nullable=True)
Note: update the ForeignKey import on the existing import line.
Step 3: Commit
git add backend/alembic/versions/047_add_survey_invites_table.py backend/app/models/survey_response.py
git commit -m "feat: add survey_invites migration and invite_id FK"
Task 3: Pydantic Schemas for Invites
Files:
- Modify:
backend/app/schemas/survey.py
Step 1: Add invite schemas and update SurveySubmission
Append to backend/app/schemas/survey.py:
from datetime import datetime
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.",
)
token: Optional[str] = Field(None, description="Invite token for tracking")
class SurveyInviteCreate(BaseModel):
"""Create a new survey invite."""
recipient_name: str = Field(..., min_length=1, max_length=255)
recipient_email: Optional[str] = Field(None, max_length=255)
send_email: bool = False
class SurveyInviteResponse(BaseModel):
"""Invite details returned to admin."""
id: str
token: str
recipient_name: str
recipient_email: Optional[str]
status: str
email_sent: bool
created_at: datetime
completed_at: Optional[datetime]
survey_url: str
class SurveyInviteStatus(BaseModel):
"""Public invite status check — minimal info."""
name: str
status: str
Note: Also add the token field to the existing SurveySubmission class (replace it entirely in the file).
Step 2: Commit
git add backend/app/schemas/survey.py
git commit -m "feat: add survey invite schemas and token field"
Task 4: Public Invite Status Endpoint + Update Submit Endpoint
Files:
- Modify:
backend/app/api/endpoints/survey.py
Step 1: Add invite status check endpoint
Add above the existing submit_survey function:
from fastapi import HTTPException
from sqlalchemy import select
from datetime import datetime, timezone
from app.models.survey_invite import SurveyInvite
from app.schemas.survey import SurveyInviteStatus
@router.get("/survey/invite/{token}", response_model=SurveyInviteStatus)
async def check_invite_status(
token: str,
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Check if a survey invite token is valid and its status."""
result = await db.execute(
select(SurveyInvite).where(SurveyInvite.token == token)
)
invite = result.scalar_one_or_none()
if not invite:
raise HTTPException(status_code=404, detail="Invalid invite token")
return SurveyInviteStatus(name=invite.recipient_name, status=invite.status)
Step 2: Update submit_survey to handle tokens
Modify submit_survey to:
- If
data.tokenis provided, look up the invite - If invite exists and is
completed, return 409 - If invite exists and is
pending, link the response and mark invite completed - If token not found, ignore (treat as open submission)
Replace the body of submit_survey with:
ip = request.client.host if request.client else None
ua = request.headers.get("user-agent", "")
invite = None
if data.token:
result = await db.execute(
select(SurveyInvite).where(SurveyInvite.token == data.token)
)
invite = result.scalar_one_or_none()
if invite and invite.status == "completed":
raise HTTPException(status_code=409, detail="This survey has already been submitted")
response = SurveyResponse(
respondent_name=data.respondent_name or (invite.recipient_name if invite else None),
responses=data.responses,
ip_address=ip,
user_agent=ua,
invite_id=invite.id if invite else None,
)
db.add(response)
if invite:
invite.status = "completed"
invite.completed_at = datetime.now(timezone.utc)
await db.flush()
try:
if settings.FEEDBACK_EMAIL:
await EmailService.send_survey_notification_email(
to_email=settings.FEEDBACK_EMAIL,
respondent_name=response.respondent_name,
responses=data.responses,
)
except Exception:
logger.exception("Failed to send survey notification email")
await db.commit()
return SurveySubmissionResponse(id=str(response.id))
Step 3: Commit
git add backend/app/api/endpoints/survey.py
git commit -m "feat: add invite status check and token validation on submit"
Task 5: Admin Invite Endpoints
Files:
- Create:
backend/app/api/endpoints/admin_survey.py - Modify:
backend/app/api/router.py
Step 1: Create admin survey endpoints
# backend/app/api/endpoints/admin_survey.py
"""Admin endpoints for managing survey invites."""
import logging
from typing import Annotated
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import require_admin
from app.core.config import settings
from app.core.database import get_db
from app.core.email import EmailService
from app.models.survey_invite import SurveyInvite
from app.models.user import User
from app.schemas.survey import SurveyInviteCreate, SurveyInviteResponse
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/admin", tags=["admin-survey"])
FRONTEND_URL = "https://resolutionflow.com"
def _build_invite_response(invite: SurveyInvite) -> SurveyInviteResponse:
base_url = FRONTEND_URL if not settings.DEBUG else "http://localhost:5173"
return SurveyInviteResponse(
id=str(invite.id),
token=invite.token,
recipient_name=invite.recipient_name,
recipient_email=invite.recipient_email,
status=invite.status,
email_sent=invite.email_sent,
created_at=invite.created_at,
completed_at=invite.completed_at,
survey_url=f"{base_url}/survey?t={invite.token}",
)
@router.post("/survey-invites", response_model=SurveyInviteResponse)
async def create_survey_invite(
data: SurveyInviteCreate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Create a survey invite. Optionally sends email."""
invite = SurveyInvite(
recipient_name=data.recipient_name,
recipient_email=data.recipient_email,
)
db.add(invite)
await db.flush()
if data.send_email and data.recipient_email:
try:
base_url = FRONTEND_URL if not settings.DEBUG else "http://localhost:5173"
survey_url = f"{base_url}/survey?t={invite.token}"
sent = await EmailService.send_survey_invite_email(
to_email=data.recipient_email,
recipient_name=data.recipient_name,
survey_url=survey_url,
)
if sent:
invite.email_sent = True
except Exception:
logger.exception("Failed to send survey invite email")
await db.commit()
await db.refresh(invite)
return _build_invite_response(invite)
@router.get("/survey-invites", response_model=list[SurveyInviteResponse])
async def list_survey_invites(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""List all survey invites."""
result = await db.execute(
select(SurveyInvite).order_by(SurveyInvite.created_at.desc())
)
invites = result.scalars().all()
return [_build_invite_response(i) for i in invites]
Step 2: Register in router.py
Add to backend/app/api/router.py:
from app.api.endpoints import admin_survey
And at the end:
api_router.include_router(admin_survey.router)
Step 3: Commit
git add backend/app/api/endpoints/admin_survey.py backend/app/api/router.py
git commit -m "feat: add admin survey invite CRUD endpoints"
Task 6: Survey Invite Email
Files:
- Modify:
backend/app/core/email.py
Step 1: Add send_survey_invite_email to EmailService
Add a new static method to EmailService (before the _render_invite_html helper functions). Follow the existing dark-themed email pattern using the Slate & Ice color scheme (#101114 bg, #06b6d4 accent, #f8fafc text):
@staticmethod
async def send_survey_invite_email(
to_email: str,
recipient_name: str,
survey_url: str,
) -> bool:
"""Send survey invite email."""
if not settings.email_enabled:
logger.warning("Email not sent — RESEND_API_KEY not configured")
return False
try:
import resend
import html as html_mod
resend.api_key = settings.RESEND_API_KEY
safe_name = html_mod.escape(recipient_name)
subject = "FlowPilot Survey — Your expertise matters"
email_html = f"""<!DOCTYPE html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"></head>
<body style="margin:0;padding:0;background:#101114;font-family:'Inter',Helvetica,Arial,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#101114;padding:40px 0;">
<tr><td align="center">
<table width="560" cellpadding="0" cellspacing="0" style="background:#14161a;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:#f8fafc;font-size:24px;font-weight:600;">Resolution<span style="color:#06b6d4;">Flow</span></h1>
<p style="margin:8px 0 0;color:#5a6170;font-size:14px;">FlowPilot Research</p>
</td></tr>
<tr><td style="padding:0 40px 24px;">
<p style="margin:0;color:#8891a0;font-size:16px;line-height:1.6;">
Hi {safe_name},
</p>
<p style="margin:12px 0 0;color:#8891a0;font-size:16px;line-height:1.6;">
We're building an AI assistant for MSP engineers and would love your input. This 5-minute survey will help shape how FlowPilot thinks about troubleshooting.
</p>
</td></tr>
<tr><td style="padding:0 40px 32px;text-align:center;">
<a href="{survey_url}" style="display:inline-block;background:linear-gradient(135deg,#06b6d4,#22d3ee);color:#101114;font-size:16px;font-weight:600;text-decoration:none;padding:14px 40px;border-radius:10px;">
Take the Survey
</a>
</td></tr>
<tr><td style="padding:0 40px 32px;">
<p style="margin:0;color:#5a6170;font-size:12px;text-align:center;">
Your responses are confidential. Takes about 5 minutes.
</p>
</td></tr>
</table>
</td></tr>
</table>
</body></html>"""
resend.Emails.send({
"from": settings.FROM_EMAIL,
"to": [to_email],
"subject": subject,
"html": email_html,
})
logger.info("Survey invite email sent to %s", to_email)
return True
except Exception:
logger.exception("Failed to send survey invite email to %s", to_email)
return False
Step 2: Commit
git add backend/app/core/email.py
git commit -m "feat: add survey invite email template"
Phase 2: Frontend
Task 7: Update SurveyPage to Handle Invite Tokens
Files:
- Modify:
frontend/src/pages/SurveyPage.tsx
Step 1: Add token handling
At the top of the component, add URL param reading and invite status check. Add these state variables and an effect:
import { useState, useCallback, useRef, useEffect } from 'react'
import { useSearchParams } from 'react-router-dom'
// Inside SurveyPage component, at the top:
const [searchParams] = useSearchParams()
const token = searchParams.get('t')
const [inviteName, setInviteName] = useState<string | null>(null)
const [alreadyCompleted, setAlreadyCompleted] = useState(false)
const [tokenLoading, setTokenLoading] = useState(!!token)
useEffect(() => {
if (!token) return
const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000'
fetch(`${apiUrl}/api/v1/survey/invite/${token}`)
.then(r => r.ok ? r.json() : null)
.then(data => {
if (data?.status === 'completed') setAlreadyCompleted(true)
if (data?.name) setInviteName(data.name)
})
.catch(() => {})
.finally(() => setTokenLoading(false))
}, [token])
Step 2: Add the "already completed" screen
After the tokenLoading early return, before the main render, add:
if (tokenLoading) {
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<div className="text-muted-foreground text-sm">Loading...</div>
</div>
)
}
if (alreadyCompleted) {
return (
<div className="min-h-screen bg-background text-foreground">
{/* Same atmosphere orbs as main page */}
<div className="relative z-10 mx-auto max-w-[680px] px-5">
<div className="text-center pt-32 animate-fade-in-up">
<div className="w-16 h-16 mx-auto mb-5 rounded-full flex items-center justify-center" style={{ background: 'rgba(6, 182, 212, 0.1)' }}>
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#06b6d4" strokeWidth="2.5"><path d="M20 6L9 17l-5-5"/></svg>
</div>
<h2 className="font-heading text-2xl font-bold mb-2.5">Already Submitted</h2>
<p className="text-muted-foreground text-sm max-w-[440px] mx-auto leading-relaxed">
{inviteName ? `Thanks ${inviteName} — y` : 'Y'}our response has already been recorded. We appreciate your time!
</p>
</div>
</div>
</div>
)
}
Step 3: Include token in submission
In the handleSubmit function, update the fetch body to include the token:
body: JSON.stringify({ respondent_name: inviteName, responses: answers, token }),
Step 4: Handle 409 response
In the handleSubmit catch block, check for 409:
if (!res.ok) {
if (res.status === 409) {
setAlreadyCompleted(true)
return
}
const data = await res.json().catch(() => null)
throw new Error(data?.detail || `Submission failed (${res.status})`)
}
Step 5: Verify build
cd frontend && npm run build
Step 6: Commit
git add frontend/src/pages/SurveyPage.tsx
git commit -m "feat: add invite token handling to survey page"
Task 8: Admin Survey Invites Page
Files:
- Create:
frontend/src/pages/admin/SurveyInvitesPage.tsx - Modify:
frontend/src/api/admin.ts - Modify:
frontend/src/components/admin/AdminSidebar.tsx - Modify:
frontend/src/router.tsx
Step 1: Add API methods to frontend/src/api/admin.ts
Before the closing } of adminApi, add:
// Survey Invites
listSurveyInvites: () =>
api.get<SurveyInviteResponse[]>('/admin/survey-invites').then(r => r.data),
createSurveyInvite: (data: { recipient_name: string; recipient_email?: string; send_email?: boolean }) =>
api.post<SurveyInviteResponse>('/admin/survey-invites', data).then(r => r.data),
Add the SurveyInviteResponse type to the imports at the top of admin.ts (you'll define it in the types file, or inline it). Simplest: add inline interface at the top of the file:
export interface SurveyInviteResponse {
id: string
token: string
recipient_name: string
recipient_email: string | null
status: string
email_sent: boolean
created_at: string
completed_at: string | null
survey_url: string
}
Step 2: Create the admin page
Create frontend/src/pages/admin/SurveyInvitesPage.tsx. This page has:
- Create section at top: name input + email input + two buttons ("Generate Link" / "Send Email")
- Generated link display with copy button (shown after creating)
- Table below showing all invites
Use the existing admin component patterns (PageHeader, DataTable, StatusBadge) and the Slate & Ice design system (glass cards, cyan accents). Follow the same patterns as InviteCodesPage.tsx.
Step 3: Add nav item to AdminSidebar
In frontend/src/components/admin/AdminSidebar.tsx, add to navItems array after the Categories entry:
import { ClipboardList } from 'lucide-react'
// In navItems array:
{ path: '/admin/survey-invites', label: 'Survey Invites', icon: ClipboardList },
Step 4: Add route to frontend/src/router.tsx
Add lazy import:
const AdminSurveyInvitesPage = lazy(() => import('@/pages/admin/SurveyInvitesPage'))
Add route as child of the admin route, after categories:
{
path: 'survey-invites',
element: (
<Suspense fallback={<PageLoader />}>
<AdminSurveyInvitesPage />
</Suspense>
),
},
Step 5: Verify build
cd frontend && npm run build
Step 6: Commit
git add frontend/src/pages/admin/SurveyInvitesPage.tsx frontend/src/api/admin.ts frontend/src/components/admin/AdminSidebar.tsx frontend/src/router.tsx
git commit -m "feat: add admin survey invites page with create and list"
Phase 3: Testing
Task 9: Backend Tests
Files:
- Modify:
backend/tests/test_survey.py
Step 1: Add invite-related tests
Append to backend/tests/test_survey.py:
@pytest.mark.asyncio
async def test_check_invite_status_not_found(client):
"""Invalid token returns 404."""
response = await client.get("/api/v1/survey/invite/nonexistent")
assert response.status_code == 404
@pytest.mark.asyncio
async def test_submit_with_completed_token_returns_409(client, admin_auth_headers):
"""Submitting with an already-used token returns 409."""
# Create invite
create_res = await client.post(
"/api/v1/admin/survey-invites",
json={"recipient_name": "Test User"},
headers=admin_auth_headers,
)
assert create_res.status_code == 200
token = create_res.json()["token"]
# First submit succeeds
submit_res = await client.post(
"/api/v1/survey/submit",
json={"responses": {"q1": "answer"}, "token": token},
)
assert submit_res.status_code == 200
# Second submit with same token returns 409
submit_res2 = await client.post(
"/api/v1/survey/submit",
json={"responses": {"q1": "answer"}, "token": token},
)
assert submit_res2.status_code == 409
@pytest.mark.asyncio
async def test_create_invite_requires_admin(client, auth_headers):
"""Non-admin users cannot create invites."""
response = await client.post(
"/api/v1/admin/survey-invites",
json={"recipient_name": "Test"},
headers=auth_headers,
)
assert response.status_code == 403
@pytest.mark.asyncio
async def test_create_and_list_invites(client, admin_auth_headers):
"""Admin can create and list invites."""
# Create
create_res = await client.post(
"/api/v1/admin/survey-invites",
json={"recipient_name": "John Smith", "recipient_email": "john@msp.example.com"},
headers=admin_auth_headers,
)
assert create_res.status_code == 200
data = create_res.json()
assert data["recipient_name"] == "John Smith"
assert data["status"] == "pending"
assert "survey_url" in data
# List
list_res = await client.get("/api/v1/admin/survey-invites", headers=admin_auth_headers)
assert list_res.status_code == 200
invites = list_res.json()
assert len(invites) >= 1
Step 2: Run tests
cd backend && pytest tests/test_survey.py -v --override-ini="addopts="
Step 3: Commit
git add backend/tests/test_survey.py
git commit -m "test: add survey invite tracking tests"
Summary
| Phase | Tasks | New Files | Modified Files |
|---|---|---|---|
| Backend | Model, migration, schemas, public endpoints, admin endpoints, email | models/survey_invite.py, api/endpoints/admin_survey.py, migration 047 |
models/__init__.py, models/survey_response.py, alembic/env.py, schemas/survey.py, api/endpoints/survey.py, api/router.py, core/email.py |
| Frontend | Survey token handling, admin page | pages/admin/SurveyInvitesPage.tsx |
pages/SurveyPage.tsx, api/admin.ts, components/admin/AdminSidebar.tsx, router.tsx |
| Testing | Backend tests | — | tests/test_survey.py |
Key constraints:
- Public survey still works without a token
- One submission per invite token (409 on reuse)
- Frontend gates completed tokens with "already submitted" screen
- Admin endpoints require
super_adminrole - Email uses Slate & Ice design (cyan gradient CTA button, dark bg)