# 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**

```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"""<!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**

```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<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:

```typescript
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:

```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<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:

```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: (
    <Suspense fallback={<PageLoader />}>
      <AdminSurveyInvitesPage />
    </Suspense>
  ),
},
```

**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)
