Files
resolutionflow/docs/plans/2026-03-04-survey-invite-tracking.md
2026-03-05 01:33:17 -05:00

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:

  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:

    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_admin role
  • Email uses Slate & Ice design (cyan gradient CTA button, dark bg)