docs: add survey invite tracking design and implementation plan

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
chihlasm
2026-03-05 01:33:17 -05:00
parent 4d2c4930fd
commit 9bb69254df
2 changed files with 895 additions and 0 deletions

View File

@@ -0,0 +1,76 @@
# Survey Invite Tracking — Design
> **Date:** 2026-03-04
> **Status:** Approved
## Goal
Add invite tracking to the FlowPilot survey so Michael can create personalized links, optionally email them, and see who has/hasn't responded. Each invite token is single-use — one submission per token.
## Data Model
### New table: `survey_invites`
| Column | Type | Notes |
|--------|------|-------|
| `id` | UUID PK | |
| `token` | VARCHAR(32) UNIQUE | Random URL-safe token |
| `recipient_name` | VARCHAR(255) NOT NULL | Who it's for |
| `recipient_email` | VARCHAR(255) NULL | Only if emailing |
| `status` | VARCHAR(20) DEFAULT 'pending' | `pending` or `completed` |
| `email_sent` | BOOLEAN DEFAULT false | Whether Resend email was sent |
| `created_at` | TIMESTAMPTZ NOT NULL | |
| `completed_at` | TIMESTAMPTZ NULL | Set on submission |
### Modified table: `survey_responses`
Add `invite_id` UUID FK nullable → `survey_invites.id`. Responses from tokenless `/survey` have `invite_id = NULL`.
## API Endpoints
### Public (no auth)
- `GET /api/v1/survey/invite/{token}` — Returns invite status (`{ name, status }`). If `completed`, frontend shows "already submitted" screen. Returns 404 for invalid tokens.
- `POST /api/v1/survey/submit` — Modified: accepts optional `token` field. If token provided, validates it's `pending`, links the response, and marks invite as `completed`. Returns 409 if token already used.
### Admin (super_admin auth)
- `POST /api/v1/admin/survey-invites` — Create invite. Body: `{ recipient_name, recipient_email?, send_email? }`. Generates token, optionally sends email. Returns the invite with the full survey URL.
- `GET /api/v1/admin/survey-invites` — List all invites with status.
## Frontend
### Survey page changes (`/survey`)
- On load, reads `?t=<token>` from URL params
- If token present: calls `GET /survey/invite/{token}`
- If `completed` → show "already submitted" screen
- If `pending` → show survey, include token in submission payload
- If 404 → show survey without token (treat as open link)
- If no token: show survey as-is (open access)
### Admin page (`/admin/survey-invites`)
**Top section: Create Invite**
- Name input (required) + Email input (optional)
- "Generate Link" button → creates invite, shows URL with copy button
- "Send Email" button → creates invite with `send_email: true`, shows confirmation toast
- "Send Email" only enabled when email field is filled
**Bottom section: Invite Table**
- Columns: Name, Email, Status badge (pending amber / completed green), Sent (email icon or dash), Created date, Completed date
- Sorted by created_at descending
## Email Template
Uses existing `EmailService` + Resend pattern. Dark-themed email matching Slate & Ice aesthetic:
- Subject: "FlowPilot Survey — Your expertise matters"
- Body: Brief intro, CTA button linking to `/survey?t=<token>`, ~5 minutes note
- From: existing `FROM_EMAIL` config
## Constraints
- No token expiration
- No reminder/resend (keep it simple)
- Tokenless survey still works for open sharing
- One submission per invite token (enforced backend + frontend)

View File

@@ -0,0 +1,819 @@
# 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)