# 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=` 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** ```python # 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`: ```python from .survey_invite import SurveyInvite ``` And in `__all__`: ```python "SurveyInvite", ``` **Step 3: Add import to `backend/alembic/env.py`** After the `SurveyResponse` import line: ```python from app.models.survey_invite import SurveyInvite ``` **Step 4: Commit** ```bash 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** ```python """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`: ```python 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** ```bash 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`: ```python 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** ```bash 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: ```python 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: 1. If `data.token` is provided, look up the invite 2. If invite exists and is `completed`, return 409 3. If invite exists and is `pending`, link the response and mark invite completed 4. If token not found, ignore (treat as open submission) Replace the body of `submit_survey` with: ```python 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** ```bash 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** ```python # 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`: ```python from app.api.endpoints import admin_survey ``` And at the end: ```python api_router.include_router(admin_survey.router) ``` **Step 3: Commit** ```bash 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): ```python @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"""

ResolutionFlow

FlowPilot Research

Hi {safe_name},

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.

Take the Survey

Your responses are confidential. Takes about 5 minutes.

""" 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** ```bash 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: ```typescript 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(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: ```typescript if (tokenLoading) { return (
Loading...
) } if (alreadyCompleted) { return (
{/* Same atmosphere orbs as main page */}

Already Submitted

{inviteName ? `Thanks ${inviteName} — y` : 'Y'}our response has already been recorded. We appreciate your time!

) } ``` **Step 3: Include token in submission** In the `handleSubmit` function, update the fetch body to include the token: ```typescript body: JSON.stringify({ respondent_name: inviteName, responses: answers, token }), ``` **Step 4: Handle 409 response** In the `handleSubmit` catch block, check for 409: ```typescript 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** ```bash cd frontend && npm run build ``` **Step 6: Commit** ```bash 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: ```typescript // Survey Invites listSurveyInvites: () => api.get('/admin/survey-invites').then(r => r.data), createSurveyInvite: (data: { recipient_name: string; recipient_email?: string; send_email?: boolean }) => api.post('/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: ```typescript 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: ```typescript 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: ```typescript const AdminSurveyInvitesPage = lazy(() => import('@/pages/admin/SurveyInvitesPage')) ``` Add route as child of the `admin` route, after `categories`: ```typescript { path: 'survey-invites', element: ( }> ), }, ``` **Step 5: Verify build** ```bash cd frontend && npm run build ``` **Step 6: Commit** ```bash 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`: ```python @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** ```bash cd backend && pytest tests/test_survey.py -v --override-ini="addopts=" ``` **Step 3: Commit** ```bash 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_admin` role - Email uses Slate & Ice design (cyan gradient CTA button, dark bg)