820 lines
25 KiB
Markdown
820 lines
25 KiB
Markdown
# 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)
|