From 9bb69254df4e40a2226180e4b9b3a5ea916009cb Mon Sep 17 00:00:00 2001 From: chihlasm Date: Thu, 5 Mar 2026 01:33:17 -0500 Subject: [PATCH] docs: add survey invite tracking design and implementation plan Co-Authored-By: Claude Opus 4.6 --- ...026-03-04-survey-invite-tracking-design.md | 76 ++ .../2026-03-04-survey-invite-tracking.md | 819 ++++++++++++++++++ 2 files changed, 895 insertions(+) create mode 100644 docs/plans/2026-03-04-survey-invite-tracking-design.md create mode 100644 docs/plans/2026-03-04-survey-invite-tracking.md diff --git a/docs/plans/2026-03-04-survey-invite-tracking-design.md b/docs/plans/2026-03-04-survey-invite-tracking-design.md new file mode 100644 index 00000000..669c9e48 --- /dev/null +++ b/docs/plans/2026-03-04-survey-invite-tracking-design.md @@ -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=` 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=`, ~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) diff --git a/docs/plans/2026-03-04-survey-invite-tracking.md b/docs/plans/2026-03-04-survey-invite-tracking.md new file mode 100644 index 00000000..c8e79e02 --- /dev/null +++ b/docs/plans/2026-03-04-survey-invite-tracking.md @@ -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=` 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)