24 tasks across 4 PRs. Addresses all spec review findings: model code uses Mapped[] syntax, standalone export functions, team-scoped access checks, variable resolution in PDF, Jinja2/CSS fix, Vitest + Playwright test tasks included. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2651 lines
85 KiB
Markdown
2651 lines
85 KiB
Markdown
# 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
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<style>
|
|
@page {
|
|
size: A4;
|
|
margin: 2cm 2.5cm;
|
|
}
|
|
/* Jinja2 conditionally generates the appropriate @page footer rules */
|
|
{% if has_custom_logo %}
|
|
@page {
|
|
@bottom-right {
|
|
content: "Powered by ResolutionFlow";
|
|
font-size: 8pt;
|
|
color: #999;
|
|
}
|
|
}
|
|
{% endif %}
|
|
@page {
|
|
@bottom-left {
|
|
content: "Generated {{ generated_at }}";
|
|
font-size: 8pt;
|
|
color: #999;
|
|
}
|
|
}
|
|
body {
|
|
font-family: -apple-system, 'Segoe UI', Helvetica, Arial, sans-serif;
|
|
color: #1a1a1a;
|
|
line-height: 1.6;
|
|
font-size: 11pt;
|
|
}
|
|
.header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: flex-start;
|
|
border-bottom: 2px solid #06b6d4;
|
|
padding-bottom: 12pt;
|
|
margin-bottom: 18pt;
|
|
}
|
|
.header-left .report-type {
|
|
font-size: 9pt;
|
|
color: #666;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
margin-bottom: 4pt;
|
|
}
|
|
.header-left .flow-title {
|
|
font-size: 16pt;
|
|
font-weight: 700;
|
|
color: #1a1a1a;
|
|
}
|
|
.header-right {
|
|
text-align: right;
|
|
}
|
|
.header-right img {
|
|
max-width: 120px;
|
|
max-height: 40px;
|
|
}
|
|
.header-right .company-name {
|
|
font-size: 9pt;
|
|
color: #999;
|
|
margin-top: 4pt;
|
|
}
|
|
.metadata-grid {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr 1fr;
|
|
gap: 10pt;
|
|
padding: 12pt;
|
|
background: #f8f9fa;
|
|
border-radius: 6pt;
|
|
border: 1px solid #e9ecef;
|
|
margin-bottom: 18pt;
|
|
}
|
|
.metadata-grid .meta-label {
|
|
font-size: 8pt;
|
|
color: #666;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
}
|
|
.metadata-grid .meta-value {
|
|
font-size: 10pt;
|
|
font-weight: 600;
|
|
}
|
|
.outcome-resolved { color: #16a34a; }
|
|
.outcome-unresolved { color: #dc2626; }
|
|
.outcome-escalated { color: #f59e0b; }
|
|
.section-heading {
|
|
font-size: 11pt;
|
|
font-weight: 700;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.03em;
|
|
border-left: 3px solid #06b6d4;
|
|
padding-left: 8pt;
|
|
margin-bottom: 10pt;
|
|
margin-top: 18pt;
|
|
}
|
|
.summary-text {
|
|
font-size: 10pt;
|
|
color: #333;
|
|
}
|
|
.timeline {
|
|
margin-left: 10pt;
|
|
border-left: 2px solid #e2e8f0;
|
|
padding-left: 14pt;
|
|
}
|
|
.timeline-step {
|
|
margin-bottom: 12pt;
|
|
position: relative;
|
|
page-break-inside: avoid;
|
|
}
|
|
.timeline-step .dot {
|
|
position: absolute;
|
|
left: -21pt;
|
|
top: 2pt;
|
|
width: 10pt;
|
|
height: 10pt;
|
|
background: #06b6d4;
|
|
border-radius: 50%;
|
|
}
|
|
.timeline-step.final .dot {
|
|
background: #16a34a;
|
|
}
|
|
.step-title {
|
|
font-size: 10pt;
|
|
font-weight: 600;
|
|
}
|
|
.step-decision {
|
|
font-size: 9pt;
|
|
color: #666;
|
|
margin-top: 2pt;
|
|
}
|
|
.supporting-data-section {
|
|
page-break-before: auto;
|
|
}
|
|
.supporting-data-item {
|
|
margin-bottom: 10pt;
|
|
padding: 10pt;
|
|
background: #f8f9fa;
|
|
border-radius: 5pt;
|
|
border: 1px solid #e9ecef;
|
|
page-break-inside: avoid;
|
|
}
|
|
.supporting-data-item .item-label {
|
|
font-size: 9pt;
|
|
font-weight: 600;
|
|
color: #666;
|
|
text-transform: uppercase;
|
|
margin-bottom: 5pt;
|
|
}
|
|
.supporting-data-item pre {
|
|
font-size: 9pt;
|
|
font-family: 'JetBrains Mono', 'Courier New', monospace;
|
|
background: #fff;
|
|
padding: 6pt;
|
|
border-radius: 3pt;
|
|
border: 1px solid #e2e8f0;
|
|
white-space: pre-wrap;
|
|
margin: 0;
|
|
}
|
|
.supporting-data-item img {
|
|
max-width: 100%;
|
|
border-radius: 3pt;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<!-- Header -->
|
|
<div class="header">
|
|
<div class="header-left">
|
|
<div class="report-type">{{ report_type }}</div>
|
|
<div class="flow-title">{{ flow_title }}</div>
|
|
</div>
|
|
<div class="header-right">
|
|
{% if logo_data %}
|
|
<img src="data:{{ logo_content_type }};base64,{{ logo_data }}" alt="{{ company_name }}">
|
|
{% endif %}
|
|
{% if company_name %}
|
|
<div class="company-name">{{ company_name }}</div>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Metadata Grid -->
|
|
<div class="metadata-grid">
|
|
<div>
|
|
<div class="meta-label">Engineer</div>
|
|
<div class="meta-value">{{ engineer_name }}</div>
|
|
</div>
|
|
<div>
|
|
<div class="meta-label">Client</div>
|
|
<div class="meta-value">{{ client_name or "—" }}</div>
|
|
</div>
|
|
<div>
|
|
<div class="meta-label">Ticket #</div>
|
|
<div class="meta-value">{{ ticket_number or "—" }}</div>
|
|
</div>
|
|
<div>
|
|
<div class="meta-label">Date</div>
|
|
<div class="meta-value">{{ session_date }}</div>
|
|
</div>
|
|
<div>
|
|
<div class="meta-label">Duration</div>
|
|
<div class="meta-value">{{ duration }}</div>
|
|
</div>
|
|
<div>
|
|
<div class="meta-label">Outcome</div>
|
|
<div class="meta-value outcome-{{ outcome_class }}">{{ outcome_display }}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Summary -->
|
|
{% if summary %}
|
|
<div class="section-heading">Summary</div>
|
|
<p class="summary-text">{{ summary }}</p>
|
|
{% endif %}
|
|
|
|
<!-- Troubleshooting Path -->
|
|
{% if steps %}
|
|
<div class="section-heading">Troubleshooting Path</div>
|
|
<div class="timeline">
|
|
{% for step in steps %}
|
|
<div class="timeline-step {% if loop.last %}final{% endif %}">
|
|
<div class="dot"></div>
|
|
<div class="step-title">{{ loop.index }}. {{ step.title }}</div>
|
|
{% if step.decision %}
|
|
<div class="step-decision">Decision: {{ step.decision }}</div>
|
|
{% endif %}
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
{% endif %}
|
|
|
|
<!-- Supporting Data -->
|
|
{% if supporting_data %}
|
|
<div class="supporting-data-section">
|
|
<div class="section-heading">Supporting Data</div>
|
|
{% for item in supporting_data %}
|
|
<div class="supporting-data-item">
|
|
<div class="item-label">{{ item.label }}</div>
|
|
{% if item.data_type == "text_snippet" %}
|
|
<pre>{{ item.content }}</pre>
|
|
{% elif item.data_type == "screenshot" %}
|
|
<img src="data:{{ item.content_type }};base64,{{ item.content }}" alt="{{ item.label }}">
|
|
{% endif %}
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
{% endif %}
|
|
|
|
</body>
|
|
</html>
|
|
```
|
|
|
|
- [ ] **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: `<pre>` blocks with labels
|
|
- Screenshots: `<img>` 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 (
|
|
<div className={cn('flex flex-col items-center justify-center py-12 text-center', className)}>
|
|
{illustration && (
|
|
<div className="mb-6 opacity-60">
|
|
{illustration}
|
|
</div>
|
|
)}
|
|
{!illustration && icon && (
|
|
<div className="mb-4 text-muted-foreground">{icon}</div>
|
|
)}
|
|
<h3 className="text-lg font-semibold text-foreground">{title}</h3>
|
|
{description && (
|
|
<p className="mt-2 max-w-sm text-sm text-muted-foreground">{description}</p>
|
|
)}
|
|
{action && <div className="mt-4">{action}</div>}
|
|
{learnMoreLink && (
|
|
<Link
|
|
to={learnMoreLink}
|
|
className="mt-3 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
|
>
|
|
{learnMoreText} →
|
|
</Link>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
```
|
|
|
|
- [ ] **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 (
|
|
<svg width="80" height="60" viewBox="0 0 80 60" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
<circle cx="40" cy="8" r="6" stroke="#06b6d4" strokeWidth="1.5" fill="rgba(6,182,212,0.15)" />
|
|
<line x1="40" y1="14" x2="25" y2="28" stroke="#06b6d4" strokeWidth="1.5" />
|
|
<line x1="40" y1="14" x2="55" y2="28" stroke="#06b6d4" strokeWidth="1.5" />
|
|
<circle cx="25" cy="32" r="5" stroke="#06b6d4" strokeWidth="1.5" fill="rgba(6,182,212,0.1)" />
|
|
<circle cx="55" cy="32" r="5" stroke="#22d3ee" strokeWidth="1.5" fill="rgba(34,211,238,0.1)" />
|
|
<line x1="25" y1="37" x2="25" y2="48" stroke="#06b6d4" strokeWidth="1.5" />
|
|
<line x1="55" y1="37" x2="55" y2="48" stroke="#22d3ee" strokeWidth="1.5" />
|
|
<circle cx="25" cy="52" r="4" stroke="#06b6d4" strokeWidth="1.5" fill="rgba(6,182,212,0.2)" />
|
|
<circle cx="55" cy="52" r="4" stroke="#22d3ee" strokeWidth="1.5" fill="rgba(34,211,238,0.2)" />
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
export function AnalyticsIllustration() {
|
|
return (
|
|
<svg width="80" height="60" viewBox="0 0 80 60" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
<rect x="5" y="30" width="14" height="25" rx="3" fill="rgba(6,182,212,0.15)" stroke="#06b6d4" strokeWidth="1" />
|
|
<rect x="23" y="20" width="14" height="35" rx="3" fill="rgba(6,182,212,0.2)" stroke="#06b6d4" strokeWidth="1" />
|
|
<rect x="41" y="10" width="14" height="45" rx="3" fill="rgba(6,182,212,0.25)" stroke="#06b6d4" strokeWidth="1" />
|
|
<rect x="59" y="5" width="14" height="50" rx="3" fill="rgba(34,211,238,0.3)" stroke="#22d3ee" strokeWidth="1" />
|
|
<line x1="2" y1="57" x2="78" y2="57" stroke="rgba(255,255,255,0.1)" strokeWidth="1" />
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
export function SessionIllustration() {
|
|
return (
|
|
<svg width="80" height="60" viewBox="0 0 80 60" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
<rect x="10" y="5" width="60" height="12" rx="4" stroke="#06b6d4" strokeWidth="1.5" fill="rgba(6,182,212,0.1)" />
|
|
<rect x="10" y="22" width="60" height="12" rx="4" stroke="#06b6d4" strokeWidth="1.5" fill="rgba(6,182,212,0.15)" />
|
|
<rect x="10" y="39" width="60" height="12" rx="4" stroke="#22d3ee" strokeWidth="1.5" fill="rgba(34,211,238,0.1)" />
|
|
<circle cx="22" cy="11" r="3" fill="rgba(6,182,212,0.4)" />
|
|
<circle cx="22" cy="28" r="3" fill="rgba(6,182,212,0.4)" />
|
|
<circle cx="22" cy="45" r="3" fill="rgba(34,211,238,0.4)" />
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
export function IntegrationIllustration() {
|
|
return (
|
|
<svg width="80" height="60" viewBox="0 0 80 60" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
<rect x="5" y="15" width="25" height="30" rx="5" stroke="#06b6d4" strokeWidth="1.5" fill="rgba(6,182,212,0.1)" />
|
|
<rect x="50" y="15" width="25" height="30" rx="5" stroke="#22d3ee" strokeWidth="1.5" fill="rgba(34,211,238,0.1)" />
|
|
<line x1="30" y1="26" x2="50" y2="26" stroke="#06b6d4" strokeWidth="1.5" strokeDasharray="4 3" />
|
|
<line x1="30" y1="34" x2="50" y2="34" stroke="#22d3ee" strokeWidth="1.5" strokeDasharray="4 3" />
|
|
<polygon points="47,23 50,26 47,29" fill="#06b6d4" />
|
|
<polygon points="33,31 30,34 33,37" fill="#22d3ee" />
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
export function StepLibraryIllustration() {
|
|
return (
|
|
<svg width="80" height="60" viewBox="0 0 80 60" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
<rect x="10" y="5" width="55" height="14" rx="4" stroke="#06b6d4" strokeWidth="1.5" fill="rgba(6,182,212,0.1)" />
|
|
<rect x="15" y="23" width="55" height="14" rx="4" stroke="#06b6d4" strokeWidth="1.5" fill="rgba(6,182,212,0.15)" />
|
|
<rect x="10" y="41" width="55" height="14" rx="4" stroke="#22d3ee" strokeWidth="1.5" fill="rgba(34,211,238,0.1)" />
|
|
<circle cx="20" cy="12" r="2.5" fill="rgba(6,182,212,0.5)" />
|
|
<circle cx="25" cy="30" r="2.5" fill="rgba(6,182,212,0.5)" />
|
|
<circle cx="20" cy="48" r="2.5" fill="rgba(34,211,238,0.5)" />
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
export function ScriptIllustration() {
|
|
return (
|
|
<svg width="80" height="60" viewBox="0 0 80 60" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
<rect x="10" y="5" width="60" height="50" rx="5" stroke="#06b6d4" strokeWidth="1.5" fill="rgba(6,182,212,0.05)" />
|
|
<line x1="18" y1="18" x2="35" y2="18" stroke="#06b6d4" strokeWidth="1.5" />
|
|
<line x1="22" y1="26" x2="50" y2="26" stroke="rgba(6,182,212,0.6)" strokeWidth="1.5" />
|
|
<line x1="22" y1="34" x2="45" y2="34" stroke="rgba(34,211,238,0.6)" strokeWidth="1.5" />
|
|
<line x1="18" y1="42" x2="38" y2="42" stroke="#22d3ee" strokeWidth="1.5" />
|
|
<text x="14" y="19" fill="rgba(6,182,212,0.4)" fontSize="8" fontFamily="monospace">></text>
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
export function ShareIllustration() {
|
|
return (
|
|
<svg width="80" height="60" viewBox="0 0 80 60" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
<circle cx="20" cy="30" r="8" stroke="#06b6d4" strokeWidth="1.5" fill="rgba(6,182,212,0.15)" />
|
|
<circle cx="60" cy="15" r="6" stroke="#22d3ee" strokeWidth="1.5" fill="rgba(34,211,238,0.1)" />
|
|
<circle cx="60" cy="45" r="6" stroke="#22d3ee" strokeWidth="1.5" fill="rgba(34,211,238,0.1)" />
|
|
<line x1="27" y1="26" x2="54" y2="17" stroke="#06b6d4" strokeWidth="1.5" />
|
|
<line x1="27" y1="34" x2="54" y2="43" stroke="#06b6d4" strokeWidth="1.5" />
|
|
</svg>
|
|
)
|
|
}
|
|
```
|
|
|
|
- [ ] **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 `<EmptyState>` usage.
|
|
|
|
**Pattern for each page:**
|
|
|
|
```tsx
|
|
import { FlowIllustration } from '@/components/common/EmptyStateIllustrations'
|
|
|
|
// In the render:
|
|
<EmptyState
|
|
illustration={<FlowIllustration />}
|
|
title="Build your first troubleshooting flow"
|
|
description="Flows guide your team through proven resolution paths, capturing every decision along the way."
|
|
action={<button className="bg-gradient-brand ...">Create a Flow</button>}
|
|
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<string, { title: string; component: React.ComponentType }> = {
|
|
'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 (
|
|
<EmptyState
|
|
title="Guide not found"
|
|
description="The guide you're looking for doesn't exist."
|
|
action={
|
|
<Link to="/" className="inline-flex items-center gap-2 rounded-[10px] bg-gradient-brand px-4 py-2 text-sm font-semibold text-[#101114] hover:opacity-90 active:scale-[0.97] transition-all">
|
|
Back to Dashboard
|
|
</Link>
|
|
}
|
|
/>
|
|
)
|
|
}
|
|
|
|
const GuideContent = guide.component
|
|
|
|
return (
|
|
<div className="mx-auto max-w-3xl px-4 py-8">
|
|
<nav className="mb-6 flex items-center gap-1 text-sm text-muted-foreground">
|
|
<Link to="/" className="hover:text-foreground transition-colors">Home</Link>
|
|
<ChevronRight className="h-3 w-3" />
|
|
<span className="text-foreground">{guide.title}</span>
|
|
</nav>
|
|
<div className="glass-card-static p-8">
|
|
<GuideContent />
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
```
|
|
|
|
- [ ] **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 (
|
|
<article>
|
|
<h1 className="text-2xl font-heading font-bold text-foreground mb-4">Creating Flows</h1>
|
|
<p className="text-muted-foreground mb-6">
|
|
Flows are the core of ResolutionFlow — structured troubleshooting paths that guide your team
|
|
through proven resolution steps.
|
|
</p>
|
|
|
|
<h2 className="text-lg font-semibold text-foreground mt-8 mb-3">Flow Types</h2>
|
|
<ul className="list-disc pl-6 text-muted-foreground space-y-2 mb-6">
|
|
<li><strong className="text-foreground">Troubleshooting</strong> — Decision trees that branch based on what the engineer finds at each step.</li>
|
|
<li><strong className="text-foreground">Projects</strong> — Step-by-step procedural guides for installations, migrations, and setups.</li>
|
|
<li><strong className="text-foreground">Maintenance</strong> — Recurring check sequences you can schedule and run in batches.</li>
|
|
</ul>
|
|
|
|
<h2 className="text-lg font-semibold text-foreground mt-8 mb-3">Creating a Flow Manually</h2>
|
|
<p className="text-muted-foreground mb-4">
|
|
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.
|
|
</p>
|
|
|
|
<h2 className="text-lg font-semibold text-foreground mt-8 mb-3">Using AI to Generate Flows</h2>
|
|
<p className="text-muted-foreground mb-4">
|
|
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.
|
|
</p>
|
|
|
|
<div className="mt-8 pt-6 border-t border-border">
|
|
<Link
|
|
to="/trees"
|
|
className="inline-flex items-center gap-2 rounded-[10px] bg-gradient-brand px-4 py-2 text-sm font-semibold text-[#101114] hover:opacity-90 active:scale-[0.97] transition-all"
|
|
>
|
|
Go to Flow Library →
|
|
</Link>
|
|
</div>
|
|
</article>
|
|
)
|
|
}
|
|
```
|
|
|
|
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 }) => (
|
|
<BrowserRouter>{children}</BrowserRouter>
|
|
)
|
|
|
|
describe('EmptyState', () => {
|
|
it('renders title and description', () => {
|
|
render(
|
|
<EmptyState title="No data" description="Nothing here yet" />,
|
|
{ wrapper }
|
|
)
|
|
expect(screen.getByText('No data')).toBeInTheDocument()
|
|
expect(screen.getByText('Nothing here yet')).toBeInTheDocument()
|
|
})
|
|
|
|
it('renders illustration when provided', () => {
|
|
render(
|
|
<EmptyState title="Test" illustration={<FlowIllustration />} />,
|
|
{ wrapper }
|
|
)
|
|
expect(document.querySelector('svg')).toBeInTheDocument()
|
|
})
|
|
|
|
it('renders action button', () => {
|
|
render(
|
|
<EmptyState title="Test" action={<button>Do Thing</button>} />,
|
|
{ wrapper }
|
|
)
|
|
expect(screen.getByText('Do Thing')).toBeInTheDocument()
|
|
})
|
|
|
|
it('renders learn more link', () => {
|
|
render(
|
|
<EmptyState title="Test" learnMoreLink="/guides/test" />,
|
|
{ wrapper }
|
|
)
|
|
const link = screen.getByText('Learn more →')
|
|
expect(link).toBeInTheDocument()
|
|
expect(link).toHaveAttribute('href', '/guides/test')
|
|
})
|
|
|
|
it('renders without optional props', () => {
|
|
render(<EmptyState title="Just a title" />, { 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<OnboardingStatus> {
|
|
const response = await apiClient.get('/users/onboarding-status')
|
|
return response.data
|
|
}
|
|
|
|
export async function dismissOnboarding(): Promise<void> {
|
|
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<OnboardingStatus | null>(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 (
|
|
<div className="glass-card p-5 mb-6">
|
|
{/* Progress bar */}
|
|
<div className="h-1 rounded-full bg-[rgba(255,255,255,0.06)] mb-4">
|
|
<div
|
|
className="h-full rounded-full bg-gradient-brand transition-all duration-500"
|
|
style={{ width: `${(completedCount / totalCount) * 100}%` }}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div>
|
|
<span className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">
|
|
Getting Started
|
|
</span>
|
|
<span className="ml-2 text-xs text-muted-foreground">
|
|
{isAllDone ? "You're all set!" : `${completedCount} of ${totalCount} complete`}
|
|
</span>
|
|
</div>
|
|
<button
|
|
onClick={handleDismiss}
|
|
className="p-1 text-muted-foreground hover:text-foreground transition-colors"
|
|
aria-label="Dismiss checklist"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="space-y-1">
|
|
{items.map((item) => {
|
|
const isComplete = status[item.key]
|
|
return (
|
|
<button
|
|
key={item.key}
|
|
onClick={() => !isComplete && navigate(item.path)}
|
|
className={cn(
|
|
'flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors',
|
|
isComplete
|
|
? 'text-muted-foreground cursor-default'
|
|
: 'text-foreground hover:bg-[rgba(255,255,255,0.04)]'
|
|
)}
|
|
>
|
|
<div
|
|
className={cn(
|
|
'flex h-5 w-5 items-center justify-center rounded-md border transition-colors',
|
|
isComplete
|
|
? 'border-primary/30 bg-primary/10'
|
|
: 'border-border'
|
|
)}
|
|
>
|
|
{isComplete && <Check className="h-3 w-3 text-primary" />}
|
|
</div>
|
|
<span className={cn(isComplete && 'line-through')}>{item.label}</span>
|
|
{!isComplete && (
|
|
<ChevronRight className="ml-auto h-4 w-4 text-muted-foreground" />
|
|
)}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
```
|
|
|
|
- [ ] **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:
|
|
<OnboardingChecklist />
|
|
```
|
|
|
|
- [ ] **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<SupportingDataItem[]> {
|
|
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<SupportingDataItem> {
|
|
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<SupportingDataItem> {
|
|
const response = await apiClient.patch(`/sessions/${sessionId}/supporting-data/${itemId}`, data)
|
|
return response.data
|
|
}
|
|
|
|
export async function deleteSupportingData(sessionId: string, itemId: string): Promise<void> {
|
|
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<BrandingInfo> {
|
|
const response = await apiClient.get(`/teams/${teamId}/branding`)
|
|
return response.data
|
|
}
|
|
|
|
export async function updateBranding(
|
|
teamId: string,
|
|
formData: FormData
|
|
): Promise<BrandingInfo> {
|
|
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<void> {
|
|
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' ? (
|
|
<div className="flex flex-col items-center justify-center py-12">
|
|
<p className="text-muted-foreground mb-4">PDF exports are generated server-side with your team's branding.</p>
|
|
<button
|
|
onClick={onDownloadPdf}
|
|
disabled={loading}
|
|
className="inline-flex items-center gap-2 rounded-[10px] bg-gradient-brand px-6 py-3 text-sm font-semibold text-[#101114] hover:opacity-90 active:scale-[0.97] transition-all"
|
|
>
|
|
{loading ? 'Generating PDF...' : 'Download PDF'}
|
|
</button>
|
|
</div>
|
|
) : (
|
|
// 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)
|