diff --git a/docs/superpowers/plans/2026-03-16-empty-states-onboarding-exports.md b/docs/superpowers/plans/2026-03-16-empty-states-onboarding-exports.md new file mode 100644 index 00000000..df04162b --- /dev/null +++ b/docs/superpowers/plans/2026-03-16-empty-states-onboarding-exports.md @@ -0,0 +1,2650 @@ +# Empty States, Onboarding & Professional Exports — Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add illustrative empty states, onboarding checklist, team branding, PDF exports, and supporting data capture to make ResolutionFlow feel polished and professional. + +**Architecture:** Bottom-up — backend foundation (migrations, endpoints, PDF generation) first, then frontend empty states + guides, then onboarding checklist, then PDF/supporting data UI. Four PRs, each independently shippable. + +**Tech Stack:** FastAPI, SQLAlchemy, Alembic, WeasyPrint, Jinja2, React 19, TypeScript, Tailwind CSS, Vitest, Playwright + +**Spec:** `docs/superpowers/specs/2026-03-16-empty-states-onboarding-exports-design.md` + +--- + +## Chunk 1: PR 1 — Backend Foundation + +### File Structure + +| Action | File | Responsibility | +|--------|------|---------------| +| Modify | `backend/app/models/user.py` | Add `onboarding_dismissed`, branding columns | +| Modify | `backend/app/models/team.py` | Add branding columns | +| Create | `backend/app/models/supporting_data.py` | SessionSupportingData model | +| Modify | `backend/app/models/__init__.py` | Import new model | +| Create | `backend/alembic/versions/*_add_onboarding_and_branding.py` | Migration: user + team columns | +| Create | `backend/alembic/versions/*_add_supporting_data_table.py` | Migration: supporting data table | +| Create | `backend/app/schemas/onboarding.py` | Onboarding status response schema | +| Create | `backend/app/schemas/branding.py` | Branding request/response schemas | +| Create | `backend/app/schemas/supporting_data.py` | Supporting data CRUD schemas | +| Modify | `backend/app/schemas/session.py:109` | Add `pdf` to format pattern | +| Create | `backend/app/api/endpoints/onboarding.py` | Onboarding status + dismiss endpoints | +| Create | `backend/app/api/endpoints/branding.py` | Team branding CRUD endpoints | +| Create | `backend/app/api/endpoints/supporting_data.py` | Supporting data CRUD endpoints | +| Modify | `backend/app/api/router.py` | Register new endpoint routers | +| Modify | `backend/app/services/export_service.py` | Add `generate_pdf_export()` function + supporting data in all formats | +| Modify | `backend/app/api/endpoints/sessions.py:371-440` | Add PDF format branch | +| Create | `backend/app/templates/export_pdf.html` | Jinja2 PDF template | +| Modify | `backend/requirements.txt` | Add `weasyprint`, `jinja2` | +| Modify | `backend/Dockerfile` | Add WeasyPrint system deps | +| Create | `backend/tests/test_onboarding.py` | Onboarding endpoint tests | +| Create | `backend/tests/test_branding.py` | Branding endpoint tests | +| Create | `backend/tests/test_supporting_data.py` | Supporting data endpoint tests | +| Create | `backend/tests/test_pdf_export.py` | PDF export tests | + +--- + +### Task 0: Create Feature Branch + +- [ ] **Step 1: Create feature branch before any commits** + +```bash +git checkout -b feat/backend-foundation-empty-states-exports +``` + +Per CLAUDE.md: "Always create feature branch BEFORE committing." + +--- + +### Task 1: Database Migrations — User & Team Branding Columns + +**Files:** +- Modify: `backend/app/models/user.py:75` (after `avatar_url`) +- Modify: `backend/app/models/team.py:25` (after `name`) +- Create: migration file + +**Note:** All model code uses `Mapped[]`/`mapped_column()` syntax to match the existing codebase pattern. Do NOT use legacy `Column()` style. + +- [ ] **Step 1: Add columns to User model** + +In `backend/app/models/user.py`, after `avatar_url` (line 75), add: + +```python + # Onboarding + onboarding_dismissed: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, server_default="false") + + # Branding (solo pros without a team) + logo_data: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + logo_content_type: Mapped[Optional[str]] = mapped_column(String(50), nullable=True) + company_display_name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) +``` + +Ensure `Optional` is imported from `typing` and `mapped_column`, `Mapped` from `sqlalchemy.orm` (should already be imported in the file). + +- [ ] **Step 2: Add columns to Team model** + +In `backend/app/models/team.py`, after `name` (line 25), add: + +```python + # Branding + logo_data: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + logo_content_type: Mapped[Optional[str]] = mapped_column(String(50), nullable=True) + company_display_name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) +``` + +- [ ] **Step 3: Generate migration** + +Run: `cd backend && alembic revision --autogenerate -m "add onboarding and branding columns"` + +- [ ] **Step 4: Review and apply migration** + +Review the generated migration file, then run: +`cd backend && alembic upgrade head` + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/models/user.py backend/app/models/team.py backend/alembic/versions/ +git commit -m "feat: add onboarding_dismissed and branding columns to user and team models" +``` + +--- + +### Task 2: SessionSupportingData Model & Migration + +**Files:** +- Create: `backend/app/models/supporting_data.py` +- Modify: `backend/app/models/__init__.py` + +- [ ] **Step 1: Create the model** + +Create `backend/app/models/supporting_data.py` using `Mapped[]`/`mapped_column()` to match existing model patterns: + +```python +import uuid +from datetime import datetime, timezone +from typing import Optional + +from sqlalchemy import DateTime, Enum, ForeignKey, Integer, String, Text +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.core.database import Base + + +class SessionSupportingData(Base): + __tablename__ = "session_supporting_data" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + primary_key=True, + default=uuid.uuid4, + ) + session_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("sessions.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + label: Mapped[str] = mapped_column(String(255), nullable=False) + data_type: Mapped[str] = mapped_column( + Enum("text_snippet", "screenshot", name="supporting_data_type"), + nullable=False, + ) + content: Mapped[str] = mapped_column(Text, nullable=False) + content_type: Mapped[Optional[str]] = mapped_column(String(50), nullable=True) + sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + nullable=False, + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + nullable=False, + ) + + session = relationship("Session", back_populates="supporting_data") +``` + +- [ ] **Step 2: Add relationship to Session model** + +In `backend/app/models/session.py`, add to the relationships section: + +```python + supporting_data = relationship("SessionSupportingData", back_populates="session", cascade="all, delete-orphan", order_by="SessionSupportingData.sort_order") +``` + +- [ ] **Step 3: Register in models __init__.py** + +In `backend/app/models/__init__.py`, add the import: + +```python +from app.models.supporting_data import SessionSupportingData +``` + +- [ ] **Step 4: Generate and apply migration** + +```bash +cd backend && alembic revision --autogenerate -m "add session_supporting_data table" +cd backend && alembic upgrade head +``` + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/models/supporting_data.py backend/app/models/__init__.py backend/app/models/session.py backend/alembic/versions/ +git commit -m "feat: add session_supporting_data model and migration" +``` + +--- + +### Task 3: Pydantic Schemas + +**Files:** +- Create: `backend/app/schemas/onboarding.py` +- Create: `backend/app/schemas/branding.py` +- Create: `backend/app/schemas/supporting_data.py` +- Modify: `backend/app/schemas/session.py:109` + +- [ ] **Step 1: Create onboarding schema** + +Create `backend/app/schemas/onboarding.py`: + +```python +from pydantic import BaseModel + + +class OnboardingStatus(BaseModel): + created_flow: bool + ran_session: bool + exported_session: bool + tried_ai_assistant: bool + invited_teammate: bool + connected_psa: bool + is_team_user: bool + dismissed: bool +``` + +- [ ] **Step 2: Create branding schemas** + +Create `backend/app/schemas/branding.py`: + +```python +from typing import Optional + +from pydantic import BaseModel, Field + + +class BrandingResponse(BaseModel): + company_display_name: Optional[str] = None + logo_content_type: Optional[str] = None + has_logo: bool = False + + +class BrandingLogoResponse(BaseModel): + company_display_name: Optional[str] = None + logo_data: Optional[str] = None + logo_content_type: Optional[str] = None +``` + +- [ ] **Step 3: Create supporting data schemas** + +Create `backend/app/schemas/supporting_data.py`: + +```python +from datetime import datetime +from typing import Literal, Optional +from uuid import UUID + +from pydantic import BaseModel, Field + + +class SupportingDataCreate(BaseModel): + label: str = Field(..., min_length=1, max_length=255) + data_type: Literal["text_snippet", "screenshot"] + content: str = Field(..., min_length=1, max_length=5_000_000) # ~2MB base64 for screenshots, 50K chars for text validated in endpoint + content_type: Optional[str] = Field(None, max_length=50) + + +class SupportingDataUpdate(BaseModel): + label: Optional[str] = Field(None, min_length=1, max_length=255) + content: Optional[str] = Field(None, min_length=1) + + +class SupportingDataResponse(BaseModel): + id: UUID + session_id: UUID + label: str + data_type: str + content: str + content_type: Optional[str] + sort_order: int + created_at: datetime + updated_at: datetime + + model_config = {"from_attributes": True} +``` + +- [ ] **Step 4: Update SessionExport format pattern** + +In `backend/app/schemas/session.py`, line 109, change: + +```python + format: str = Field(default="markdown", pattern="^(text|markdown|html|psa)$") +``` + +to: + +```python + format: str = Field(default="markdown", pattern="^(text|markdown|html|psa|pdf)$") +``` + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/schemas/onboarding.py backend/app/schemas/branding.py backend/app/schemas/supporting_data.py backend/app/schemas/session.py +git commit -m "feat: add onboarding, branding, and supporting data schemas" +``` + +--- + +### Task 4: Onboarding Endpoints + +**Files:** +- Create: `backend/app/api/endpoints/onboarding.py` +- Modify: `backend/app/api/router.py` + +**Important:** The existing `conftest.py` only provides `client`, `test_user`, `auth_headers`, and `test_tree` fixtures. Tests that need team admins, engineers, teams, or sessions must create them inline. Follow the pattern in existing test files — register a user via the API, create a team, etc. within each test or via a local fixture in the test file. + +- [ ] **Step 1: Write the onboarding status test** + +Create `backend/tests/test_onboarding.py`: + +```python +import pytest +from httpx import AsyncClient + + +@pytest.mark.asyncio +async def test_onboarding_status_fresh_user(client: AsyncClient, auth_headers: dict): + """Fresh user should have all items false.""" + response = await client.get("/api/v1/users/onboarding-status", headers=auth_headers) + assert response.status_code == 200 + data = response.json() + assert data["created_flow"] is False + assert data["ran_session"] is False + assert data["exported_session"] is False + assert data["tried_ai_assistant"] is False + assert data["dismissed"] is False + + +@pytest.mark.asyncio +async def test_onboarding_dismiss(client: AsyncClient, auth_headers: dict): + """Dismiss endpoint should set dismissed to true.""" + response = await client.post("/api/v1/users/onboarding-status/dismiss", headers=auth_headers) + assert response.status_code == 200 + + response = await client.get("/api/v1/users/onboarding-status", headers=auth_headers) + assert response.json()["dismissed"] is True +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd backend && pytest tests/test_onboarding.py -v --override-ini="addopts="` +Expected: FAIL — endpoint does not exist yet. + +- [ ] **Step 3: Create the onboarding endpoint** + +Create `backend/app/api/endpoints/onboarding.py`: + +```python +from typing import Annotated + +from fastapi import APIRouter, Depends +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_current_active_user +from app.core.database import get_db +from app.models.session import Session +from app.models.tree import Tree +from app.models.user import User +from app.schemas.onboarding import OnboardingStatus + +router = APIRouter(prefix="/users", tags=["onboarding"]) + + +@router.get("/onboarding-status", response_model=OnboardingStatus) +async def get_onboarding_status( + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_active_user)], +) -> OnboardingStatus: + user_id = current_user.id + team_id = current_user.team_id + + # Check created_flow + flow_count = await db.scalar( + select(func.count()).select_from(Tree).where(Tree.created_by == user_id) + ) + + # Check ran_session + session_count = await db.scalar( + select(func.count()).select_from(Session).where(Session.user_id == user_id) + ) + + # Check exported_session + exported_count = await db.scalar( + select(func.count()) + .select_from(Session) + .where(Session.user_id == user_id, Session.exported == True) + ) + + # Check tried_ai_assistant + from app.models.assistant_chat import AssistantChat + ai_count = await db.scalar( + select(func.count()).select_from(AssistantChat).where(AssistantChat.user_id == user_id) + ) + + # Check team-specific items + is_team_user = team_id is not None + invited_teammate = False + connected_psa = False + + if is_team_user: + team_member_count = await db.scalar( + select(func.count()).select_from(User).where(User.team_id == team_id) + ) + invited_teammate = (team_member_count or 0) > 1 + + from app.models.psa_connection import PsaConnection + psa_count = await db.scalar( + select(func.count()) + .select_from(PsaConnection) + .where(PsaConnection.team_id == team_id) + ) + connected_psa = (psa_count or 0) > 0 + + return OnboardingStatus( + created_flow=(flow_count or 0) > 0, + ran_session=(session_count or 0) > 0, + exported_session=(exported_count or 0) > 0, + tried_ai_assistant=(ai_count or 0) > 0, + invited_teammate=invited_teammate, + connected_psa=connected_psa, + is_team_user=is_team_user, + dismissed=current_user.onboarding_dismissed, + ) + + +@router.post("/onboarding-status/dismiss") +async def dismiss_onboarding( + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_active_user)], +): + current_user.onboarding_dismissed = True + db.add(current_user) + await db.commit() + return {"status": "dismissed"} +``` + +- [ ] **Step 4: Register in router** + +In `backend/app/api/router.py`, add import and include: + +```python +from app.api.endpoints import onboarding +# ... +api_router.include_router(onboarding.router) +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `cd backend && pytest tests/test_onboarding.py -v --override-ini="addopts="` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add backend/app/api/endpoints/onboarding.py backend/app/api/router.py backend/tests/test_onboarding.py +git commit -m "feat: add onboarding status and dismiss endpoints with tests" +``` + +--- + +### Task 5: Branding Endpoints + +**Files:** +- Create: `backend/app/api/endpoints/branding.py` +- Create: `backend/tests/test_branding.py` +- Modify: `backend/app/api/router.py` + +- [ ] **Step 1: Write branding tests** + +Create `backend/tests/test_branding.py`. **Note:** `team_admin_headers`, `engineer_headers`, and `test_team_id` don't exist in conftest.py. Each test must create a team and users inline via the API (register user, create team, log in to get headers). Follow existing test patterns in the codebase — read other test files first to see how they set up users/teams. + +The tests must cover: +- Get branding with no logo returns defaults (has_logo=False) +- Upload a valid 1x1 PNG logo with company name — verify has_logo=True +- Upload oversized file (>2MB) — returns 400 +- Upload invalid content type (application/pdf) — returns 400 +- Delete logo — clears it +- Non-admin cannot update branding — returns 403 +- Non-team-member cannot read branding — returns 403 + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd backend && pytest tests/test_branding.py -v --override-ini="addopts="` +Expected: FAIL — endpoints don't exist. + +- [ ] **Step 3: Create branding endpoint** + +Create `backend/app/api/endpoints/branding.py`: + +```python +import base64 +from typing import Annotated, Optional +from uuid import UUID + +from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_current_active_user +from app.core.database import get_db +from app.models.team import Team +from app.models.user import User +from app.schemas.branding import BrandingResponse + +router = APIRouter(prefix="/teams", tags=["branding"]) + +ALLOWED_CONTENT_TYPES = {"image/png", "image/jpeg", "image/svg+xml"} +MAX_LOGO_SIZE = 2 * 1024 * 1024 # 2MB + + +async def _get_team_or_404(db: AsyncSession, team_id: UUID) -> Team: + team = await db.get(Team, team_id) + if not team: + raise HTTPException(status_code=404, detail="Team not found") + return team + + +def _require_team_admin(user: User, team_id: UUID) -> None: + if user.is_super_admin: + return + if not user.is_team_admin or user.team_id != team_id: + raise HTTPException(status_code=403, detail="Team admin required") + + +def _require_team_member(user: User, team_id: UUID) -> None: + if user.is_super_admin: + return + if user.team_id != team_id: + raise HTTPException(status_code=403, detail="Not a member of this team") + + +@router.get("/{team_id}/branding", response_model=BrandingResponse) +async def get_branding( + team_id: UUID, + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_active_user)], +) -> BrandingResponse: + _require_team_member(current_user, team_id) + team = await _get_team_or_404(db, team_id) + return BrandingResponse( + company_display_name=team.company_display_name, + logo_content_type=team.logo_content_type, + has_logo=team.logo_data is not None, + ) + + +@router.patch("/{team_id}/branding", response_model=BrandingResponse) +async def update_branding( + team_id: UUID, + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_active_user)], + logo: Annotated[Optional[UploadFile], File()] = None, + company_display_name: Annotated[Optional[str], Form()] = None, +) -> BrandingResponse: + _require_team_admin(current_user, team_id) + team = await _get_team_or_404(db, team_id) + + if logo is not None: + if logo.content_type not in ALLOWED_CONTENT_TYPES: + raise HTTPException( + status_code=400, + detail=f"Invalid content type. Allowed: {', '.join(ALLOWED_CONTENT_TYPES)}", + ) + logo_bytes = await logo.read() + if len(logo_bytes) > MAX_LOGO_SIZE: + raise HTTPException(status_code=400, detail="Logo must be under 2MB") + team.logo_data = base64.b64encode(logo_bytes).decode("utf-8") + team.logo_content_type = logo.content_type + + if company_display_name is not None: + team.company_display_name = company_display_name + + db.add(team) + await db.commit() + await db.refresh(team) + + return BrandingResponse( + company_display_name=team.company_display_name, + logo_content_type=team.logo_content_type, + has_logo=team.logo_data is not None, + ) + + +@router.delete("/{team_id}/branding/logo") +async def delete_logo( + team_id: UUID, + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_active_user)], +): + _require_team_admin(current_user, team_id) + team = await _get_team_or_404(db, team_id) + team.logo_data = None + team.logo_content_type = None + db.add(team) + await db.commit() + return {"status": "logo_deleted"} +``` + +- [ ] **Step 4: Register in router** + +In `backend/app/api/router.py`, add: + +```python +from app.api.endpoints import branding +api_router.include_router(branding.router) +``` + +- [ ] **Step 5: Run tests** + +Run: `cd backend && pytest tests/test_branding.py -v --override-ini="addopts="` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add backend/app/api/endpoints/branding.py backend/tests/test_branding.py backend/app/api/router.py +git commit -m "feat: add team branding CRUD endpoints with tests" +``` + +--- + +### Task 6: Supporting Data Endpoints + +**Files:** +- Create: `backend/app/api/endpoints/supporting_data.py` +- Create: `backend/tests/test_supporting_data.py` +- Modify: `backend/app/api/router.py` + +- [ ] **Step 1: Write supporting data tests** + +Create `backend/tests/test_supporting_data.py`: + +```python +import pytest +from httpx import AsyncClient + + +@pytest.mark.asyncio +async def test_create_text_snippet(client: AsyncClient, auth_headers: dict, test_session_id: str): + """Create a text snippet supporting data item.""" + response = await client.post( + f"/api/v1/sessions/{test_session_id}/supporting-data", + headers=auth_headers, + json={ + "label": "Ping Output", + "data_type": "text_snippet", + "content": "PING 8.8.8.8: 64 bytes from 8.8.8.8: icmp_seq=1 ttl=117", + }, + ) + assert response.status_code == 201 + data = response.json() + assert data["label"] == "Ping Output" + assert data["data_type"] == "text_snippet" + + +@pytest.mark.asyncio +async def test_create_screenshot(client: AsyncClient, auth_headers: dict, test_session_id: str): + """Create a screenshot supporting data item.""" + response = await client.post( + f"/api/v1/sessions/{test_session_id}/supporting-data", + headers=auth_headers, + json={ + "label": "Error Dialog", + "data_type": "screenshot", + "content": "iVBORw0KGgoAAAANSUhEUg==", + "content_type": "image/png", + }, + ) + assert response.status_code == 201 + assert response.json()["data_type"] == "screenshot" + + +@pytest.mark.asyncio +async def test_list_supporting_data(client: AsyncClient, auth_headers: dict, test_session_id: str): + """List returns items in sort order.""" + response = await client.get( + f"/api/v1/sessions/{test_session_id}/supporting-data", + headers=auth_headers, + ) + assert response.status_code == 200 + assert isinstance(response.json(), list) + + +@pytest.mark.asyncio +async def test_delete_supporting_data(client: AsyncClient, auth_headers: dict, test_session_id: str): + """Delete removes item.""" + # Create an item first + create_resp = await client.post( + f"/api/v1/sessions/{test_session_id}/supporting-data", + headers=auth_headers, + json={"label": "To Delete", "data_type": "text_snippet", "content": "temp"}, + ) + item_id = create_resp.json()["id"] + + response = await client.delete( + f"/api/v1/sessions/{test_session_id}/supporting-data/{item_id}", + headers=auth_headers, + ) + assert response.status_code == 200 + + +@pytest.mark.asyncio +async def test_exceed_max_items(client: AsyncClient, auth_headers: dict, test_session_id: str): + """Reject when exceeding 20 items per session.""" + for i in range(20): + await client.post( + f"/api/v1/sessions/{test_session_id}/supporting-data", + headers=auth_headers, + json={"label": f"Item {i}", "data_type": "text_snippet", "content": "data"}, + ) + response = await client.post( + f"/api/v1/sessions/{test_session_id}/supporting-data", + headers=auth_headers, + json={"label": "Item 21", "data_type": "text_snippet", "content": "data"}, + ) + assert response.status_code == 400 + + +@pytest.mark.asyncio +async def test_exceed_screenshot_size(client: AsyncClient, auth_headers: dict, test_session_id: str): + """Reject screenshots over 2MB.""" + import base64 + big_content = base64.b64encode(b"x" * (2 * 1024 * 1024 + 1)).decode() + response = await client.post( + f"/api/v1/sessions/{test_session_id}/supporting-data", + headers=auth_headers, + json={ + "label": "Big Screenshot", + "data_type": "screenshot", + "content": big_content, + "content_type": "image/png", + }, + ) + assert response.status_code == 400 +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd backend && pytest tests/test_supporting_data.py -v --override-ini="addopts="` +Expected: FAIL + +- [ ] **Step 3: Create supporting data endpoint** + +Create `backend/app/api/endpoints/supporting_data.py`: + +```python +import base64 +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_current_active_user +from app.core.database import get_db +from app.models.session import Session +from app.models.supporting_data import SessionSupportingData +from app.models.user import User +from app.schemas.supporting_data import ( + SupportingDataCreate, + SupportingDataResponse, + SupportingDataUpdate, +) + +router = APIRouter(prefix="/sessions", tags=["supporting-data"]) + +MAX_ITEMS_PER_SESSION = 20 +MAX_SCREENSHOT_SIZE = 2 * 1024 * 1024 # 2MB raw (before base64) + + +async def _get_session_or_404(db: AsyncSession, session_id: UUID) -> Session: + session = await db.get(Session, session_id) + if not session: + raise HTTPException(status_code=404, detail="Session not found") + return session + + +async def _check_session_access(user: User, session: Session, db: AsyncSession) -> None: + if user.is_super_admin: + return + if session.user_id == user.id: + return + # Team admins can only access sessions from their own team members + if user.is_team_admin and user.team_id is not None: + session_owner = await db.get(User, session.user_id) + if session_owner and session_owner.team_id == user.team_id: + return + raise HTTPException(status_code=403, detail="Access denied") + + +@router.post( + "/{session_id}/supporting-data", + response_model=SupportingDataResponse, + status_code=status.HTTP_201_CREATED, +) +async def create_supporting_data( + session_id: UUID, + data: SupportingDataCreate, + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_active_user)], +) -> SupportingDataResponse: + session = await _get_session_or_404(db, session_id) + await _check_session_access(current_user, session, db) + + # Check item limit + count = await db.scalar( + select(func.count()) + .select_from(SessionSupportingData) + .where(SessionSupportingData.session_id == session_id) + ) + if (count or 0) >= MAX_ITEMS_PER_SESSION: + raise HTTPException(status_code=400, detail=f"Maximum {MAX_ITEMS_PER_SESSION} items per session") + + # Check text snippet length + if data.data_type == "text_snippet" and len(data.content) > 50_000: + raise HTTPException(status_code=400, detail="Text snippet must be under 50,000 characters") + + # Check screenshot size (base64 decode to get raw size) + if data.data_type == "screenshot": + try: + raw_bytes = base64.b64decode(data.content) + if len(raw_bytes) > MAX_SCREENSHOT_SIZE: + raise HTTPException(status_code=400, detail="Screenshot must be under 2MB") + except Exception as e: + if isinstance(e, HTTPException): + raise + raise HTTPException(status_code=400, detail="Invalid base64 content") + + # Get next sort_order + max_order = await db.scalar( + select(func.max(SessionSupportingData.sort_order)) + .where(SessionSupportingData.session_id == session_id) + ) + next_order = (max_order or 0) + 1 + + item = SessionSupportingData( + session_id=session_id, + label=data.label, + data_type=data.data_type, + content=data.content, + content_type=data.content_type, + sort_order=next_order, + ) + db.add(item) + await db.commit() + await db.refresh(item) + return SupportingDataResponse.model_validate(item) + + +@router.get("/{session_id}/supporting-data", response_model=list[SupportingDataResponse]) +async def list_supporting_data( + session_id: UUID, + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_active_user)], +) -> list[SupportingDataResponse]: + session = await _get_session_or_404(db, session_id) + await _check_session_access(current_user, session, db) + + result = await db.execute( + select(SessionSupportingData) + .where(SessionSupportingData.session_id == session_id) + .order_by(SessionSupportingData.sort_order) + ) + items = result.scalars().all() + return [SupportingDataResponse.model_validate(item) for item in items] + + +@router.patch("/{session_id}/supporting-data/{item_id}", response_model=SupportingDataResponse) +async def update_supporting_data( + session_id: UUID, + item_id: UUID, + data: SupportingDataUpdate, + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_active_user)], +) -> SupportingDataResponse: + session = await _get_session_or_404(db, session_id) + await _check_session_access(current_user, session, db) + + item = await db.get(SessionSupportingData, item_id) + if not item or item.session_id != session_id: + raise HTTPException(status_code=404, detail="Supporting data item not found") + + if data.label is not None: + item.label = data.label + if data.content is not None: + item.content = data.content + + db.add(item) + await db.commit() + await db.refresh(item) + return SupportingDataResponse.model_validate(item) + + +@router.delete("/{session_id}/supporting-data/{item_id}") +async def delete_supporting_data( + session_id: UUID, + item_id: UUID, + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_active_user)], +): + session = await _get_session_or_404(db, session_id) + await _check_session_access(current_user, session, db) + + item = await db.get(SessionSupportingData, item_id) + if not item or item.session_id != session_id: + raise HTTPException(status_code=404, detail="Supporting data item not found") + + await db.delete(item) + await db.commit() + return {"status": "deleted"} +``` + +- [ ] **Step 4: Register in router** + +In `backend/app/api/router.py`, add: + +```python +from app.api.endpoints import supporting_data +api_router.include_router(supporting_data.router) +``` + +- [ ] **Step 5: Run tests** + +Run: `cd backend && pytest tests/test_supporting_data.py -v --override-ini="addopts="` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add backend/app/api/endpoints/supporting_data.py backend/tests/test_supporting_data.py backend/app/api/router.py +git commit -m "feat: add supporting data CRUD endpoints with tests" +``` + +--- + +### Task 7: PDF Export — WeasyPrint Setup & Template + +**Files:** +- Modify: `backend/requirements.txt` +- Modify: `backend/Dockerfile` +- Create: `backend/app/templates/export_pdf.html` + +- [ ] **Step 1: Add WeasyPrint to requirements** + +Add to `backend/requirements.txt`: + +``` +weasyprint>=62.0 +jinja2>=3.1.0 +``` + +- [ ] **Step 2: Install locally** + +```bash +cd backend && pip install weasyprint jinja2 +``` + +Note: WeasyPrint requires system libraries. On Ubuntu/Debian: +```bash +sudo apt-get install -y libpango1.0-dev libcairo2-dev libgdk-pixbuf2.0-dev libffi-dev +``` + +- [ ] **Step 3: Update Dockerfile** + +In `backend/Dockerfile`, update the apt-get line to include WeasyPrint deps: + +Change: +```dockerfile +RUN apt-get update && apt-get install -y gcc libpq-dev && rm -rf /var/lib/apt/lists/* +``` + +To: +```dockerfile +RUN apt-get update && apt-get install -y gcc libpq-dev libpango1.0-dev libcairo2-dev libgdk-pixbuf2.0-dev libffi-dev && rm -rf /var/lib/apt/lists/* +``` + +- [ ] **Step 4: Create PDF HTML template** + +Create `backend/app/templates/export_pdf.html`: + +```html + + + + + + + + + +
+
+
{{ report_type }}
+
{{ flow_title }}
+
+
+ {% if logo_data %} + {{ company_name }} + {% endif %} + {% if company_name %} +
{{ company_name }}
+ {% endif %} +
+
+ + +
+
+
Engineer
+
{{ engineer_name }}
+
+
+
Client
+
{{ client_name or "—" }}
+
+
+
Ticket #
+
{{ ticket_number or "—" }}
+
+
+
Date
+
{{ session_date }}
+
+
+
Duration
+
{{ duration }}
+
+
+
Outcome
+
{{ outcome_display }}
+
+
+ + +{% if summary %} +
Summary
+

{{ summary }}

+{% endif %} + + +{% if steps %} +
Troubleshooting Path
+
+ {% for step in steps %} +
+
+
{{ loop.index }}. {{ step.title }}
+ {% if step.decision %} +
Decision: {{ step.decision }}
+ {% endif %} +
+ {% endfor %} +
+{% endif %} + + +{% if supporting_data %} +
+
Supporting Data
+ {% for item in supporting_data %} +
+
{{ item.label }}
+ {% if item.data_type == "text_snippet" %} +
{{ item.content }}
+ {% elif item.data_type == "screenshot" %} + {{ item.label }} + {% endif %} +
+ {% endfor %} +
+{% endif %} + + + +``` + +- [ ] **Step 5: Commit** + +```bash +git add backend/requirements.txt backend/Dockerfile backend/app/templates/ +git commit -m "feat: add WeasyPrint dependency, Dockerfile system deps, and PDF template" +``` + +--- + +### Task 8: PDF Generation in Export Service + Endpoint + +**Files:** +- Modify: `backend/app/services/export_service.py` +- Modify: `backend/app/api/endpoints/sessions.py:371-440` +- Create: `backend/tests/test_pdf_export.py` + +- [ ] **Step 1: Write PDF export tests** + +Create `backend/tests/test_pdf_export.py`: + +```python +import pytest +from httpx import AsyncClient + + +@pytest.mark.asyncio +async def test_export_pdf_format(client: AsyncClient, auth_headers: dict, test_session_id: str): + """Export as PDF returns application/pdf content type.""" + response = await client.post( + f"/api/v1/sessions/{test_session_id}/export", + headers=auth_headers, + json={"format": "pdf"}, + ) + assert response.status_code == 200 + assert response.headers["content-type"] == "application/pdf" + assert response.content[:4] == b"%PDF" + + +@pytest.mark.asyncio +async def test_export_pdf_no_supporting_data(client: AsyncClient, auth_headers: dict, test_session_id: str): + """PDF export works when session has no supporting data.""" + response = await client.post( + f"/api/v1/sessions/{test_session_id}/export", + headers=auth_headers, + json={"format": "pdf"}, + ) + assert response.status_code == 200 + assert response.content[:4] == b"%PDF" + + +@pytest.mark.asyncio +async def test_export_markdown_still_works(client: AsyncClient, auth_headers: dict, test_session_id: str): + """Existing markdown export still works after PDF addition.""" + response = await client.post( + f"/api/v1/sessions/{test_session_id}/export", + headers=auth_headers, + json={"format": "markdown"}, + ) + assert response.status_code == 200 + assert "text/plain" in response.headers["content-type"] +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd backend && pytest tests/test_pdf_export.py -v --override-ini="addopts="` +Expected: FAIL + +- [ ] **Step 3: Add generate_pdf_export function to export_service.py** + +**Important:** The export service uses standalone module-level functions (NOT a class). Add this as a standalone `async def` matching the pattern of `generate_markdown_export()`, `generate_text_export()`, etc. + +Add the following function to `backend/app/services/export_service.py`: + +```python +async def generate_pdf_export( + session: "Session", + options: "SessionExport", + db: "AsyncSession", + ) -> bytes: + """Generate a branded PDF export using WeasyPrint.""" + import weasyprint + from jinja2 import Environment, FileSystemLoader + from pathlib import Path + from datetime import datetime, timezone + + from app.models.supporting_data import SessionSupportingData + from sqlalchemy import select + + # Load template + template_dir = Path(__file__).parent.parent / "templates" + env = Environment(loader=FileSystemLoader(str(template_dir))) + template = env.get_template("export_pdf.html") + + # Get tree snapshot data + tree_snapshot = session.tree_snapshot or {} + flow_title = tree_snapshot.get("title", "Untitled Flow") + tree_type = tree_snapshot.get("tree_type", "troubleshooting") + + report_type_map = { + "troubleshooting": "Troubleshooting Report", + "procedural": "Project Report", + "maintenance": "Maintenance Report", + } + report_type = report_type_map.get(tree_type, "Session Report") + + # Get branding + logo_data = None + logo_content_type = None + company_name = None + has_custom_logo = False + + user = session.user + if user and user.team_id: + from app.models.team import Team + team = await db.get(Team, user.team_id) + if team: + if team.logo_data: + logo_data = team.logo_data + logo_content_type = team.logo_content_type + has_custom_logo = True + company_name = team.company_display_name or team.name + elif user: + if user.logo_data: + logo_data = user.logo_data + logo_content_type = user.logo_content_type + has_custom_logo = True + company_name = user.company_display_name + + # Build steps from decisions + steps = [] + for decision in (session.decisions or []): + steps.append({ + "title": decision.get("title") or decision.get("question") or decision.get("description", "Step"), + "decision": decision.get("selected_option") or decision.get("answer", ""), + }) + + # Get supporting data + result = await db.execute( + select(SessionSupportingData) + .where(SessionSupportingData.session_id == session.id) + .order_by(SessionSupportingData.sort_order) + ) + supporting_data_items = result.scalars().all() + + # Calculate duration + duration = "—" + if session.started_at and session.completed_at: + delta = session.completed_at - session.started_at + minutes = int(delta.total_seconds() / 60) + if minutes < 60: + duration = f"{minutes} min" + else: + hours = minutes // 60 + remaining = minutes % 60 + duration = f"{hours}h {remaining}m" + + # Outcome display + outcome = session.outcome or "In Progress" + outcome_class = "resolved" if outcome == "resolved" else "unresolved" if outcome == "unresolved" else "escalated" + outcome_display = f"✓ {outcome.title()}" if outcome == "resolved" else outcome.title() + + # Session date + session_date = "" + if session.started_at: + session_date = session.started_at.strftime("%B %d, %Y") + + # Summary + summary = session.outcome_notes or "" + + # Engineer name + engineer_name = user.name if user else "Unknown" + + # Generated timestamp + generated_at = datetime.now(timezone.utc).strftime("%B %d, %Y at %I:%M %p UTC") + + # Render HTML + html_content = template.render( + report_type=report_type, + flow_title=flow_title, + logo_data=logo_data, + logo_content_type=logo_content_type, + has_custom_logo=has_custom_logo, + company_name=company_name, + engineer_name=engineer_name, + client_name=session.client_name, + ticket_number=session.ticket_number, + session_date=session_date, + duration=duration, + outcome_class=outcome_class, + outcome_display=outcome_display, + summary=summary, + steps=steps, + supporting_data=supporting_data_items, + generated_at=generated_at, + ) + + # Generate PDF + pdf_bytes = weasyprint.HTML(string=html_content).write_pdf() + return pdf_bytes +``` + +- [ ] **Step 4: Update export endpoint for PDF format** + +In `backend/app/api/endpoints/sessions.py`, in the `export_session` function, add the PDF branch. After the format dispatch block (around line 395-406), add: + +```python + if export_options.format == "pdf": + from app.services.export_service import generate_pdf_export + + pdf_bytes = await generate_pdf_export(session, export_options, db) + + # Mark as exported if completed (same logic as other formats) + if session.completed_at and not session.exported: + session.exported = True + db.add(session) + await db.commit() + + from fastapi.responses import Response + return Response( + content=pdf_bytes, + media_type="application/pdf", + headers={ + "Content-Disposition": f'attachment; filename="session-export-{session_id}.pdf"' + }, + ) +``` + +Make sure this block runs before the existing format dispatch so it returns early for PDF. + +**Note:** Variable resolution and redaction are handled inside `generate_pdf_export()` before rendering — the function must call `resolve_variables()` on text content and `apply_redaction_to_text()` if `redaction_mode != "none"`, matching the pattern used by other export formats in the existing code. + +- [ ] **Step 5: Run tests** + +Run: `cd backend && pytest tests/test_pdf_export.py -v --override-ini="addopts="` +Expected: PASS + +- [ ] **Step 6: Run full test suite to verify no regressions** + +Run: `cd backend && pytest --override-ini="addopts="` +Expected: All existing tests still pass. + +- [ ] **Step 7: Commit** + +```bash +git add backend/app/services/export_service.py backend/app/api/endpoints/sessions.py backend/tests/test_pdf_export.py +git commit -m "feat: add PDF export generation via WeasyPrint with branded template" +``` + +--- + +### Task 9: Supporting Data in Non-PDF Export Formats + +**Files:** +- Modify: `backend/app/services/export_service.py` + +The spec requires supporting data to be included in ALL export formats, not just PDF. + +- [ ] **Step 1: Add supporting data to generate_markdown_export** + +After the existing decisions/steps section, add a "## Supporting Data" section that renders each item: +- Text snippets: labeled fenced code blocks +- Screenshots: `[Screenshot: {label}]` placeholder (base64 images don't work in plain markdown) + +- [ ] **Step 2: Add supporting data to generate_text_export** + +After the steps section, add a "SUPPORTING DATA" section: +- Text snippets: labeled indented blocks +- Screenshots: `[Screenshot: {label}]` placeholder + +- [ ] **Step 3: Add supporting data to generate_html_export** + +After the steps section, add a "Supporting Data" section: +- Text snippets: `
` blocks with labels
+- Screenshots: `` tags with base64 src
+
+- [ ] **Step 4: Add supporting data to generate_psa_export**
+
+After the steps section, add a "Supporting Data" section:
+- Text snippets: labeled code blocks (markdown format for CW notes)
+- Screenshots: `[Screenshot: {label}]` placeholder
+
+**Note:** All four functions need to accept `db: AsyncSession` as a parameter (or the supporting data items as a pre-fetched list) to load the session's supporting data. Read the existing function signatures and follow the established pattern.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add backend/app/services/export_service.py
+git commit -m "feat: include supporting data in all export formats"
+```
+
+---
+
+### Task 10: PR 1 Final — Integration Test Run & PR
+
+- [ ] **Step 1: Run full backend test suite**
+
+```bash
+cd backend && pytest --override-ini="addopts=" -v
+```
+
+Expected: All tests pass.
+
+- [ ] **Step 2: Push feature branch and create PR**
+
+```bash
+git push -u origin feat/backend-foundation-empty-states-exports
+```
+
+Create PR with title: "feat: backend foundation for empty states, onboarding, and exports"
+
+---
+
+## Chunk 2: PR 2 — Empty States + Guides
+
+### File Structure
+
+| Action | File | Responsibility |
+|--------|------|---------------|
+| Modify | `frontend/src/components/common/EmptyState.tsx` | Add illustration, learnMoreLink props |
+| Create | `frontend/src/components/common/EmptyStateIllustrations.tsx` | SVG illustrations for each page |
+| Modify | `frontend/src/pages/TreeLibraryPage.tsx` | Upgraded empty state |
+| Modify | `frontend/src/pages/MyAnalyticsPage.tsx` | Upgraded empty state |
+| Modify | `frontend/src/pages/TeamAnalyticsPage.tsx` | Upgraded empty state |
+| Modify | `frontend/src/pages/SessionHistoryPage.tsx` | Upgraded empty state |
+| Modify | `frontend/src/pages/StepLibraryPage.tsx` | Add empty state |
+| Modify | `frontend/src/pages/ScriptLibraryPage.tsx` | Add empty state |
+| Modify | `frontend/src/pages/MySharesPage.tsx` | Upgraded empty state |
+| Modify | relevant integrations page | Add empty state |
+| Create | `frontend/src/pages/guides/GuidePage.tsx` | Guide route wrapper |
+| Create | `frontend/src/pages/guides/CreatingFlowsGuide.tsx` | Guide content |
+| Create | `frontend/src/pages/guides/UnderstandingAnalyticsGuide.tsx` | Guide content |
+| Create | `frontend/src/pages/guides/RunningSessionsGuide.tsx` | Guide content |
+| Create | `frontend/src/pages/guides/PsaSetupGuide.tsx` | Guide content |
+| Create | `frontend/src/pages/guides/StepLibraryGuide.tsx` | Guide content |
+| Create | `frontend/src/pages/guides/ScriptTemplatesGuide.tsx` | Guide content |
+| Create | `frontend/src/pages/guides/SharingSessionsGuide.tsx` | Guide content |
+| Modify | `frontend/src/router.tsx` | Add `/guides/:slug` route |
+
+---
+
+### Task 11: Upgrade EmptyState Component
+
+**Files:**
+- Modify: `frontend/src/components/common/EmptyState.tsx`
+- Create: `frontend/src/components/common/EmptyStateIllustrations.tsx`
+
+- [ ] **Step 1: Update EmptyState component**
+
+Rewrite `frontend/src/components/common/EmptyState.tsx` to support the new illustrative style:
+
+```tsx
+import { ReactNode } from 'react'
+import { Link } from 'react-router-dom'
+import { cn } from '@/lib/utils'
+
+interface EmptyStateProps {
+  icon?: ReactNode
+  illustration?: ReactNode
+  title: string
+  description?: string
+  action?: ReactNode
+  learnMoreLink?: string
+  learnMoreText?: string
+  className?: string
+}
+
+export function EmptyState({
+  icon,
+  illustration,
+  title,
+  description,
+  action,
+  learnMoreLink,
+  learnMoreText = 'Learn more',
+  className,
+}: EmptyStateProps) {
+  return (
+    
+ {illustration && ( +
+ {illustration} +
+ )} + {!illustration && icon && ( +
{icon}
+ )} +

{title}

+ {description && ( +

{description}

+ )} + {action &&
{action}
} + {learnMoreLink && ( + + {learnMoreText} → + + )} +
+ ) +} +``` + +- [ ] **Step 2: Create illustrations file** + +Create `frontend/src/components/common/EmptyStateIllustrations.tsx` with SVG illustrations for each page. Each illustration is a simple 80x60 SVG using brand colors: + +```tsx +export function FlowIllustration() { + return ( + + + + + + + + + + + + ) +} + +export function AnalyticsIllustration() { + return ( + + + + + + + + ) +} + +export function SessionIllustration() { + return ( + + + + + + + + + ) +} + +export function IntegrationIllustration() { + return ( + + + + + + + + + ) +} + +export function StepLibraryIllustration() { + return ( + + + + + + + + + ) +} + +export function ScriptIllustration() { + return ( + + + + + + + > + + ) +} + +export function ShareIllustration() { + return ( + + + + + + + + ) +} +``` + +- [ ] **Step 3: Verify frontend build** + +Run: `cd frontend && npm run build` +Expected: Build succeeds. + +- [ ] **Step 4: Commit** + +```bash +git add frontend/src/components/common/EmptyState.tsx frontend/src/components/common/EmptyStateIllustrations.tsx +git commit -m "feat: upgrade EmptyState component with illustration and learn more support" +``` + +--- + +### Task 12: Roll Out Empty States Across Pages + +**Files:** 8 page files (see file structure above) + +- [ ] **Step 1: Update each page's empty state** + +For each page, update the empty state usage to include the new props (illustration, description, CTA, learnMoreLink). The specific edits depend on how each page currently renders its empty state — read each file and update the `` usage. + +**Pattern for each page:** + +```tsx +import { FlowIllustration } from '@/components/common/EmptyStateIllustrations' + +// In the render: +} + title="Build your first troubleshooting flow" + description="Flows guide your team through proven resolution paths, capturing every decision along the way." + action={} + learnMoreLink="/guides/creating-flows" +/> +``` + +Apply the correct illustration, title, description, CTA, and guide link from the spec table for each of the 8 pages. + +- [ ] **Step 2: Add empty states to pages that don't have them** + +For StepLibraryPage, ScriptLibraryPage, and the integrations page — add the `EmptyState` component where no data exists. Read each file first to understand the current rendering logic. + +- [ ] **Step 3: Verify frontend build** + +Run: `cd frontend && npm run build` +Expected: Build succeeds with no TypeScript errors. + +- [ ] **Step 4: Commit** + +```bash +git add frontend/src/pages/ +git commit -m "feat: roll out illustrative empty states across 8 pages" +``` + +--- + +### Task 13: Create Guide Pages & Route + +**Files:** +- Create: 7 guide component files in `frontend/src/pages/guides/` +- Create: `frontend/src/pages/guides/GuidePage.tsx` +- Modify: `frontend/src/router.tsx` + +- [ ] **Step 1: Create guide page wrapper** + +Create `frontend/src/pages/guides/GuidePage.tsx`: + +```tsx +import { useParams, Link } from 'react-router-dom' +import { ChevronRight } from 'lucide-react' +import { EmptyState } from '@/components/common/EmptyState' +import CreatingFlowsGuide from './CreatingFlowsGuide' +import UnderstandingAnalyticsGuide from './UnderstandingAnalyticsGuide' +import RunningSessionsGuide from './RunningSessionsGuide' +import PsaSetupGuide from './PsaSetupGuide' +import StepLibraryGuide from './StepLibraryGuide' +import ScriptTemplatesGuide from './ScriptTemplatesGuide' +import SharingSessionsGuide from './SharingSessionsGuide' + +const guides: Record = { + 'creating-flows': { title: 'Creating Flows', component: CreatingFlowsGuide }, + 'understanding-analytics': { title: 'Understanding Analytics', component: UnderstandingAnalyticsGuide }, + 'running-sessions': { title: 'Running Sessions', component: RunningSessionsGuide }, + 'psa-setup': { title: 'Connecting Your PSA', component: PsaSetupGuide }, + 'step-library': { title: 'Using the Step Library', component: StepLibraryGuide }, + 'script-templates': { title: 'Script Templates', component: ScriptTemplatesGuide }, + 'sharing-sessions': { title: 'Sharing Sessions', component: SharingSessionsGuide }, +} + +export default function GuidePage() { + const { slug } = useParams<{ slug: string }>() + const guide = slug ? guides[slug] : undefined + + if (!guide) { + return ( + + Back to Dashboard + + } + /> + ) + } + + const GuideContent = guide.component + + return ( +
+ +
+ +
+
+ ) +} +``` + +- [ ] **Step 2: Create each guide component** + +Create 7 guide files in `frontend/src/pages/guides/`. Each follows the same pattern — a functional component with heading, paragraphs, and a CTA link. Example for `CreatingFlowsGuide.tsx`: + +```tsx +import { Link } from 'react-router-dom' + +export default function CreatingFlowsGuide() { + return ( +
+

Creating Flows

+

+ Flows are the core of ResolutionFlow — structured troubleshooting paths that guide your team + through proven resolution steps. +

+ +

Flow Types

+
    +
  • Troubleshooting — Decision trees that branch based on what the engineer finds at each step.
  • +
  • Projects — Step-by-step procedural guides for installations, migrations, and setups.
  • +
  • Maintenance — Recurring check sequences you can schedule and run in batches.
  • +
+ +

Creating a Flow Manually

+

+ Click "Create a Flow" from the Flow Library, choose your flow type, and start building + in the visual editor. Add decision nodes, connect paths, and define outcomes. +

+ +

Using AI to Generate Flows

+

+ Describe your troubleshooting scenario in plain language and the AI assistant will generate + a complete flow structure. You can then refine it in the editor. +

+ +
+ + Go to Flow Library → + +
+
+ ) +} +``` + +Create the remaining 6 guides following the same pattern, tailored to their topic per the spec table. Keep each 300-600 words. + +- [ ] **Step 3: Add route to router.tsx** + +In `frontend/src/router.tsx`, add the guide route inside the protected children: + +```tsx +{ path: 'guides/:slug', element: page(GuidePage) } +``` + +Add the lazy import at the top: +```tsx +const GuidePage = lazy(() => import('./pages/guides/GuidePage')) +``` + +- [ ] **Step 4: Verify frontend build** + +Run: `cd frontend && npm run build` +Expected: Build succeeds. + +- [ ] **Step 5: Commit** + +```bash +git add frontend/src/pages/guides/ frontend/src/router.tsx +git commit -m "feat: add 7 in-app user guides with /guides/:slug route" +``` + +--- + +### Task 14: EmptyState Vitest Tests + +**Files:** +- Create: `frontend/src/components/common/__tests__/EmptyState.test.tsx` + +- [ ] **Step 1: Write tests** + +Create `frontend/src/components/common/__tests__/EmptyState.test.tsx`: + +```tsx +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import { BrowserRouter } from 'react-router-dom' +import { EmptyState } from '../EmptyState' +import { FlowIllustration } from '../EmptyStateIllustrations' + +const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +) + +describe('EmptyState', () => { + it('renders title and description', () => { + render( + , + { wrapper } + ) + expect(screen.getByText('No data')).toBeInTheDocument() + expect(screen.getByText('Nothing here yet')).toBeInTheDocument() + }) + + it('renders illustration when provided', () => { + render( + } />, + { wrapper } + ) + expect(document.querySelector('svg')).toBeInTheDocument() + }) + + it('renders action button', () => { + render( + Do Thing} />, + { wrapper } + ) + expect(screen.getByText('Do Thing')).toBeInTheDocument() + }) + + it('renders learn more link', () => { + render( + , + { wrapper } + ) + const link = screen.getByText('Learn more →') + expect(link).toBeInTheDocument() + expect(link).toHaveAttribute('href', '/guides/test') + }) + + it('renders without optional props', () => { + render(, { wrapper }) + expect(screen.getByText('Just a title')).toBeInTheDocument() + expect(screen.queryByText('Learn more →')).not.toBeInTheDocument() + }) +}) +``` + +- [ ] **Step 2: Run tests** + +Run: `cd frontend && npx vitest run src/components/common/__tests__/EmptyState.test.tsx` +Expected: PASS + +- [ ] **Step 3: Commit** + +```bash +git add frontend/src/components/common/__tests__/EmptyState.test.tsx +git commit -m "test: add EmptyState component Vitest tests" +``` + +--- + +### Task 15: PR 2 — Build Verification & PR + +- [ ] **Step 1: Full frontend build** + +```bash +cd frontend && npm run build +``` + +Expected: Clean build, no errors. + +- [ ] **Step 2: Create feature branch and PR** + +```bash +git checkout -b feat/empty-states-and-guides +git push -u origin feat/empty-states-and-guides +``` + +Create PR: "feat: illustrative empty states across 8 pages with in-app guides" + +--- + +## Chunk 3: PR 3 — Onboarding Checklist + +### File Structure + +| Action | File | Responsibility | +|--------|------|---------------| +| Create | `frontend/src/components/dashboard/OnboardingChecklist.tsx` | Checklist widget component | +| Create | `frontend/src/api/onboarding.ts` | API client for onboarding endpoints | +| Modify | `frontend/src/pages/QuickStartPage.tsx` | Insert checklist widget | + +--- + +### Task 16: Onboarding API Client + +**Files:** +- Create: `frontend/src/api/onboarding.ts` + +- [ ] **Step 1: Create API client** + +Create `frontend/src/api/onboarding.ts`: + +```typescript +import { apiClient } from './client' + +export interface OnboardingStatus { + created_flow: boolean + ran_session: boolean + exported_session: boolean + tried_ai_assistant: boolean + invited_teammate: boolean + connected_psa: boolean + is_team_user: boolean + dismissed: boolean +} + +export async function getOnboardingStatus(): Promise { + const response = await apiClient.get('/users/onboarding-status') + return response.data +} + +export async function dismissOnboarding(): Promise { + await apiClient.post('/users/onboarding-status/dismiss') +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add frontend/src/api/onboarding.ts +git commit -m "feat: add onboarding status API client" +``` + +--- + +### Task 17: OnboardingChecklist Component + +**Files:** +- Create: `frontend/src/components/dashboard/OnboardingChecklist.tsx` + +- [ ] **Step 1: Create the component** + +Create `frontend/src/components/dashboard/OnboardingChecklist.tsx`: + +```tsx +import { useEffect, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { Check, X, ChevronRight } from 'lucide-react' +import { cn } from '@/lib/utils' +import { getOnboardingStatus, dismissOnboarding, OnboardingStatus } from '@/api/onboarding' + +interface ChecklistItem { + key: keyof OnboardingStatus + label: string + path: string +} + +const SOLO_ITEMS: ChecklistItem[] = [ + { key: 'created_flow', label: 'Create your first flow', path: '/trees' }, + { key: 'ran_session', label: 'Run your first session', path: '/trees' }, + { key: 'exported_session', label: 'Export a session', path: '/sessions' }, + { key: 'tried_ai_assistant', label: 'Try the AI assistant', path: '/assistant' }, +] + +const TEAM_ITEMS: ChecklistItem[] = [ + { key: 'created_flow', label: 'Create your first flow', path: '/trees' }, + { key: 'invited_teammate', label: 'Invite a team member', path: '/account' }, + { key: 'ran_session', label: 'Run your first session', path: '/trees' }, + { key: 'connected_psa', label: 'Connect a PSA integration', path: '/account/integrations' }, + { key: 'exported_session', label: 'Export a session', path: '/sessions' }, +] + +export function OnboardingChecklist() { + const [status, setStatus] = useState(null) + const [dismissed, setDismissed] = useState(false) + const [allComplete, setAllComplete] = useState(false) + const navigate = useNavigate() + + useEffect(() => { + getOnboardingStatus() + .then(setStatus) + .catch(() => {}) + }, []) + + if (!status || status.dismissed || dismissed) return null + + const items = status.is_team_user ? TEAM_ITEMS : SOLO_ITEMS + const completedCount = items.filter((item) => status[item.key]).length + const totalCount = items.length + const isAllDone = completedCount === totalCount + + // Show "all set" briefly then auto-hide after 2 seconds + useEffect(() => { + if (isAllDone) { + const timer = setTimeout(() => setAllComplete(true), 2000) + return () => clearTimeout(timer) + } + }, [isAllDone]) + + if (allComplete) return null + + const handleDismiss = async () => { + setDismissed(true) + await dismissOnboarding().catch(() => {}) + } + + return ( +
+ {/* Progress bar */} +
+
+
+ +
+
+ + Getting Started + + + {isAllDone ? "You're all set!" : `${completedCount} of ${totalCount} complete`} + +
+ +
+ +
+ {items.map((item) => { + const isComplete = status[item.key] + return ( + + ) + })} +
+
+ ) +} +``` + +- [ ] **Step 2: Add to QuickStartPage** + +In `frontend/src/pages/QuickStartPage.tsx`, import and insert the checklist after the greeting section (after line ~279, before the calendar/stats section): + +```tsx +import { OnboardingChecklist } from '@/components/dashboard/OnboardingChecklist' + +// In the render, after the greeting div: + +``` + +- [ ] **Step 3: Verify frontend build** + +Run: `cd frontend && npm run build` +Expected: Build succeeds. + +- [ ] **Step 4: Commit** + +```bash +git add frontend/src/components/dashboard/OnboardingChecklist.tsx frontend/src/pages/QuickStartPage.tsx +git commit -m "feat: add onboarding checklist widget to dashboard" +``` + +--- + +### Task 18: PR 3 — Build & PR + +- [ ] **Step 1: Full frontend build** + +```bash +cd frontend && npm run build +``` + +- [ ] **Step 2: Create branch and PR** + +```bash +git checkout -b feat/onboarding-checklist +git push -u origin feat/onboarding-checklist +``` + +Create PR: "feat: onboarding starter checklist on QuickStartPage" + +--- + +## Chunk 4: PR 4 — PDF Export UI + Supporting Data UI + Branding Settings + +### File Structure + +| Action | File | Responsibility | +|--------|------|---------------| +| Create | `frontend/src/api/supportingData.ts` | Supporting data API client | +| Create | `frontend/src/api/branding.ts` | Branding API client | +| Create | `frontend/src/components/session/SupportingDataPanel.tsx` | Supporting data list + add modal | +| Create | `frontend/src/components/session/AddSupportingDataModal.tsx` | Add text snippet / screenshot modal | +| Modify | `frontend/src/components/session/ExportPreviewModal.tsx` | PDF format + download-only mode | +| Modify | `frontend/src/pages/SessionDetailPage.tsx` | Add PDF format option | +| Create | `frontend/src/components/settings/BrandingSettings.tsx` | Logo upload + company name form | +| Modify | relevant team settings page | Add branding section | +| Modify | session runner pages | Add supporting data button | + +--- + +### Task 19: Supporting Data & Branding API Clients + +**Files:** +- Create: `frontend/src/api/supportingData.ts` +- Create: `frontend/src/api/branding.ts` + +- [ ] **Step 1: Create supporting data API client** + +Create `frontend/src/api/supportingData.ts`: + +```typescript +import { apiClient } from './client' + +export interface SupportingDataItem { + id: string + session_id: string + label: string + data_type: 'text_snippet' | 'screenshot' + content: string + content_type: string | null + sort_order: number + created_at: string + updated_at: string +} + +export async function getSupportingData(sessionId: string): Promise { + const response = await apiClient.get(`/sessions/${sessionId}/supporting-data`) + return response.data +} + +export async function createSupportingData( + sessionId: string, + data: { label: string; data_type: string; content: string; content_type?: string } +): Promise { + const response = await apiClient.post(`/sessions/${sessionId}/supporting-data`, data) + return response.data +} + +export async function updateSupportingData( + sessionId: string, + itemId: string, + data: { label?: string; content?: string } +): Promise { + const response = await apiClient.patch(`/sessions/${sessionId}/supporting-data/${itemId}`, data) + return response.data +} + +export async function deleteSupportingData(sessionId: string, itemId: string): Promise { + await apiClient.delete(`/sessions/${sessionId}/supporting-data/${itemId}`) +} +``` + +- [ ] **Step 2: Create branding API client** + +Create `frontend/src/api/branding.ts`: + +```typescript +import { apiClient } from './client' + +export interface BrandingInfo { + company_display_name: string | null + logo_content_type: string | null + has_logo: boolean +} + +export async function getBranding(teamId: string): Promise { + const response = await apiClient.get(`/teams/${teamId}/branding`) + return response.data +} + +export async function updateBranding( + teamId: string, + formData: FormData +): Promise { + const response = await apiClient.patch(`/teams/${teamId}/branding`, formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }) + return response.data +} + +export async function deleteLogo(teamId: string): Promise { + await apiClient.delete(`/teams/${teamId}/branding/logo`) +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add frontend/src/api/supportingData.ts frontend/src/api/branding.ts +git commit -m "feat: add supporting data and branding API clients" +``` + +--- + +### Task 20: Supporting Data UI Components + +**Files:** +- Create: `frontend/src/components/session/AddSupportingDataModal.tsx` +- Create: `frontend/src/components/session/SupportingDataPanel.tsx` + +- [ ] **Step 1: Create the add modal** + +Create `frontend/src/components/session/AddSupportingDataModal.tsx` with: +- Two tabs: "Text Snippet" and "Screenshot" +- Text tab: label input + multiline textarea +- Screenshot tab: label input + drag-and-drop zone + file picker + paste support +- Submit button that calls `createSupportingData` +- 2MB file size validation for screenshots +- Uses glass-card modal styling per design system + +- [ ] **Step 2: Create the panel** + +Create `frontend/src/components/session/SupportingDataPanel.tsx` with: +- Collapsible section showing supporting data items +- Each item: type icon (Code2 for text, Image for screenshot) + label + preview + delete button +- "Add Supporting Data" button that opens the modal +- Fetches items via `getSupportingData` on mount +- Uses the design system's glass card and muted foreground styles + +- [ ] **Step 3: Integrate into session runner pages** + +Read the troubleshooting session runner (`TreeNavigationPage`) and procedural session runner (`ProceduralNavigationPage`) to find the notes/scratchpad area. Add the `SupportingDataPanel` component near the existing notes input in both pages. + +- [ ] **Step 4: Verify frontend build** + +Run: `cd frontend && npm run build` +Expected: Build succeeds. + +- [ ] **Step 5: Commit** + +```bash +git add frontend/src/components/session/AddSupportingDataModal.tsx frontend/src/components/session/SupportingDataPanel.tsx frontend/src/pages/ +git commit -m "feat: add supporting data capture UI to session runners" +``` + +--- + +### Task 21: PDF Export in ExportPreviewModal + +**Files:** +- Modify: `frontend/src/components/session/ExportPreviewModal.tsx` +- Modify: `frontend/src/pages/SessionDetailPage.tsx` + +- [ ] **Step 1: Update ExportPreviewModal** + +Read `frontend/src/components/session/ExportPreviewModal.tsx` and modify: + +1. Update format type to include `'pdf'`: +```typescript +format: 'markdown' | 'text' | 'html' | 'psa' | 'pdf' +``` + +2. Add a `onDownloadPdf` callback prop for triggering the PDF download. + +3. When `format === 'pdf'`, hide the textarea and show a download-only UI: +```tsx +{format === 'pdf' ? ( +
+

PDF exports are generated server-side with your team's branding.

+ +
+) : ( + // existing textarea + copy/download buttons +)} +``` + +- [ ] **Step 2: Update SessionDetailPage** + +In `frontend/src/pages/SessionDetailPage.tsx`, add 'pdf' to the format options and implement the PDF download handler: + +```typescript +const handleDownloadPdf = async () => { + setLoading(true) + try { + const response = await apiClient.post( + `/sessions/${sessionId}/export`, + { format: 'pdf' }, + { responseType: 'blob' } + ) + const url = URL.createObjectURL(response.data) + const a = document.createElement('a') + a.href = url + a.download = `session-export-${sessionId}.pdf` + a.click() + URL.revokeObjectURL(url) + } catch (error) { + console.error('PDF export failed:', error) + } finally { + setLoading(false) + } +} +``` + +- [ ] **Step 3: Verify frontend build** + +Run: `cd frontend && npm run build` +Expected: Build succeeds. + +- [ ] **Step 4: Commit** + +```bash +git add frontend/src/components/session/ExportPreviewModal.tsx frontend/src/pages/SessionDetailPage.tsx +git commit -m "feat: add PDF export option with download-only mode in export modal" +``` + +--- + +### Task 22: Branding Settings UI + +**Files:** +- Create: `frontend/src/components/settings/BrandingSettings.tsx` +- Modify: relevant team settings page + +- [ ] **Step 1: Create BrandingSettings component** + +Create `frontend/src/components/settings/BrandingSettings.tsx` with: +- Company display name text input +- Logo upload area (drag-and-drop + file picker) +- Logo preview showing current logo +- Delete logo button +- Save button that calls `updateBranding` with FormData +- 2MB file size validation +- Accepts PNG, JPG, SVG only +- Preview of how logo appears on export (small mockup showing header layout) + +- [ ] **Step 2: Add to team settings page** + +Read the team settings page and add a "Branding" section with the `BrandingSettings` component. Only visible to team admins. + +- [ ] **Step 3: Verify frontend build** + +Run: `cd frontend && npm run build` +Expected: Build succeeds. + +- [ ] **Step 4: Commit** + +```bash +git add frontend/src/components/settings/BrandingSettings.tsx frontend/src/pages/ +git commit -m "feat: add branding settings UI for team logo and company name" +``` + +--- + +### Task 23: Playwright E2E Tests + +**Files:** +- Create: `frontend/e2e/empty-states.spec.ts` +- Create: `frontend/e2e/guides.spec.ts` + +- [ ] **Step 1: Write empty state Playwright test** + +Create `frontend/e2e/empty-states.spec.ts`: + +```typescript +import { test, expect } from '@playwright/test' + +test.describe('Empty States', () => { + test('Flow Library shows empty state with CTA and Learn more', async ({ page }) => { + // Login as a fresh user (or user with no flows) + // Navigate to /trees + // Verify empty state illustration is visible + // Verify "Build your first troubleshooting flow" title + // Verify "Create a Flow" CTA button + // Verify "Learn more →" link pointing to /guides/creating-flows + await page.goto('/trees') + await expect(page.getByText('Build your first troubleshooting flow')).toBeVisible() + await expect(page.getByText('Learn more →')).toBeVisible() + }) +}) +``` + +- [ ] **Step 2: Write guide page Playwright test** + +Create `frontend/e2e/guides.spec.ts`: + +```typescript +import { test, expect } from '@playwright/test' + +test.describe('Guide Pages', () => { + test('Guide page loads from Learn more link', async ({ page }) => { + await page.goto('/guides/creating-flows') + await expect(page.getByText('Creating Flows')).toBeVisible() + }) + + test('Unknown guide slug shows not-found state', async ({ page }) => { + await page.goto('/guides/nonexistent') + await expect(page.getByText('Guide not found')).toBeVisible() + }) +}) +``` + +**Note:** These tests need authentication setup. Use the existing Playwright auth fixtures from `frontend/e2e/fixtures/auth.ts`. Adapt the test user and login pattern to the project's existing Playwright setup. Read the existing e2e tests first. + +- [ ] **Step 3: Run Playwright tests** + +Run: `cd frontend && npx playwright test e2e/empty-states.spec.ts e2e/guides.spec.ts` +Expected: PASS + +- [ ] **Step 4: Commit** + +```bash +git add frontend/e2e/empty-states.spec.ts frontend/e2e/guides.spec.ts +git commit -m "test: add Playwright e2e tests for empty states and guides" +``` + +--- + +### Task 24: PR 4 — Final Build & PR + +- [ ] **Step 1: Full frontend build** + +```bash +cd frontend && npm run build +``` + +- [ ] **Step 2: Full backend test suite** + +```bash +cd backend && pytest --override-ini="addopts=" -v +``` + +- [ ] **Step 3: Push branch and create PR** + +```bash +git push -u origin feat/pdf-export-supporting-data-branding-ui +``` + +Create PR: "feat: PDF export, supporting data capture, and branding settings UI" + +--- + +## Post-Implementation + +- [ ] Update `CURRENT-STATE.md` — move empty states, onboarding, and exports to completed +- [ ] Update `03-DEVELOPMENT-ROADMAP.md` — check off these items +- [ ] Close related GitHub issues +- [ ] Update `CLAUDE.md` if new patterns emerged (e.g., guide page pattern, WeasyPrint usage)