docs: add survey invite tracking design and implementation plan
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
76
docs/plans/2026-03-04-survey-invite-tracking-design.md
Normal file
76
docs/plans/2026-03-04-survey-invite-tracking-design.md
Normal 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)
|
||||
819
docs/plans/2026-03-04-survey-invite-tracking.md
Normal file
819
docs/plans/2026-03-04-survey-invite-tracking.md
Normal 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)
|
||||
Reference in New Issue
Block a user