diff --git a/.gitignore b/.gitignore index 8fe2a9d1..cfb5aab2 100644 --- a/.gitignore +++ b/.gitignore @@ -220,6 +220,12 @@ frontend/playwright-report/ frontend/test-results/ frontend/e2e/.auth/ +# Superpowers brainstorming mockups +.superpowers/ + +# Test results (root level) +test-results/ + # Railway CLI (local tooling) node_modules/ package.json diff --git a/backend/Dockerfile b/backend/Dockerfile index 0bccd57a..1cbc6ea6 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -6,6 +6,10 @@ WORKDIR /app RUN apt-get update && apt-get install -y \ gcc \ libpq-dev \ + libpango1.0-dev \ + libcairo2-dev \ + libgdk-pixbuf-2.0-dev \ + libffi-dev \ && rm -rf /var/lib/apt/lists/* # Install Python dependencies diff --git a/backend/alembic/versions/21ddb46ddd05_add_onboarding_and_branding_columns.py b/backend/alembic/versions/21ddb46ddd05_add_onboarding_and_branding_columns.py new file mode 100644 index 00000000..4627141a --- /dev/null +++ b/backend/alembic/versions/21ddb46ddd05_add_onboarding_and_branding_columns.py @@ -0,0 +1,41 @@ +"""add onboarding and branding columns + +Revision ID: 21ddb46ddd05 +Revises: 061 +Create Date: 2026-03-16 23:30:48.910485 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '21ddb46ddd05' +down_revision: Union[str, None] = '061' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Users: onboarding + branding columns + op.add_column('users', sa.Column('onboarding_dismissed', sa.Boolean(), server_default='false', nullable=False)) + op.add_column('users', sa.Column('logo_data', sa.Text(), nullable=True)) + op.add_column('users', sa.Column('logo_content_type', sa.String(length=50), nullable=True)) + op.add_column('users', sa.Column('company_display_name', sa.String(length=255), nullable=True)) + + # Teams: branding columns + op.add_column('teams', sa.Column('logo_data', sa.Text(), nullable=True)) + op.add_column('teams', sa.Column('logo_content_type', sa.String(length=50), nullable=True)) + op.add_column('teams', sa.Column('company_display_name', sa.String(length=255), nullable=True)) + + +def downgrade() -> None: + op.drop_column('teams', 'company_display_name') + op.drop_column('teams', 'logo_content_type') + op.drop_column('teams', 'logo_data') + op.drop_column('users', 'company_display_name') + op.drop_column('users', 'logo_content_type') + op.drop_column('users', 'logo_data') + op.drop_column('users', 'onboarding_dismissed') diff --git a/backend/alembic/versions/ee98013dd18c_add_session_supporting_data_table.py b/backend/alembic/versions/ee98013dd18c_add_session_supporting_data_table.py new file mode 100644 index 00000000..9a4ecdef --- /dev/null +++ b/backend/alembic/versions/ee98013dd18c_add_session_supporting_data_table.py @@ -0,0 +1,41 @@ +"""add session_supporting_data table + +Revision ID: ee98013dd18c +Revises: 21ddb46ddd05 +Create Date: 2026-03-16 23:31:43.483511 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'ee98013dd18c' +down_revision: Union[str, None] = '21ddb46ddd05' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table('session_supporting_data', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('session_id', sa.UUID(), nullable=False), + sa.Column('label', sa.String(length=255), nullable=False), + sa.Column('data_type', sa.Enum('text_snippet', 'screenshot', name='supporting_data_type'), nullable=False), + sa.Column('content', sa.Text(), nullable=False), + sa.Column('content_type', sa.String(length=50), nullable=True), + sa.Column('sort_order', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['session_id'], ['sessions.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + ) + op.create_index(op.f('ix_session_supporting_data_session_id'), 'session_supporting_data', ['session_id'], unique=False) + + +def downgrade() -> None: + op.drop_index(op.f('ix_session_supporting_data_session_id'), table_name='session_supporting_data') + op.drop_table('session_supporting_data') + op.execute("DROP TYPE IF EXISTS supporting_data_type") diff --git a/backend/app/api/endpoints/branding.py b/backend/app/api/endpoints/branding.py new file mode 100644 index 00000000..7e22b2f7 --- /dev/null +++ b/backend/app/api/endpoints/branding.py @@ -0,0 +1,130 @@ +"""Team branding endpoints — logo upload and company display name.""" + +import base64 +from typing import Annotated, Optional +from uuid import UUID + +from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_current_active_user, 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 + + +def _require_team_member(user: User, team_id: UUID) -> None: + """Ensure the user belongs to the given team (or is super admin).""" + if user.is_super_admin: + return + if user.team_id != team_id: + raise HTTPException(status_code=403, detail="Not a member of this team") + + +def _require_team_admin(user: User, team_id: UUID) -> None: + """Ensure the user is a team admin for the given team (or is super admin).""" + 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") + + +@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: + """Retrieve branding info for a team. Any team member can read.""" + _require_team_member(current_user, team_id) + + result = await db.execute(select(Team).where(Team.id == team_id)) + team = result.scalar_one_or_none() + if not team: + raise HTTPException(status_code=404, detail="Team not found") + + 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: + """Upload logo and/or update company display name. Team admin only.""" + _require_team_admin(current_user, team_id) + + result = await db.execute(select(Team).where(Team.id == team_id)) + team = result.scalar_one_or_none() + if not team: + raise HTTPException(status_code=404, detail="Team not found") + + # Handle logo upload + if logo is not None: + if logo.content_type not in ALLOWED_CONTENT_TYPES: + raise HTTPException( + status_code=400, + detail=f"Invalid content type '{logo.content_type}'. Allowed: {', '.join(sorted(ALLOWED_CONTENT_TYPES))}", + ) + + raw_bytes = await logo.read() + if len(raw_bytes) > MAX_LOGO_SIZE: + raise HTTPException( + status_code=400, + detail=f"Logo exceeds maximum size of {MAX_LOGO_SIZE // (1024 * 1024)}MB", + ) + + team.logo_data = base64.b64encode(raw_bytes).decode("utf-8") + team.logo_content_type = logo.content_type + + # Handle display name update + if company_display_name is not None: + team.company_display_name = company_display_name + + 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", status_code=200, response_model=BrandingResponse) +async def delete_logo( + team_id: UUID, + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_active_user)], +) -> BrandingResponse: + """Remove the team logo. Team admin only.""" + _require_team_admin(current_user, team_id) + + result = await db.execute(select(Team).where(Team.id == team_id)) + team = result.scalar_one_or_none() + if not team: + raise HTTPException(status_code=404, detail="Team not found") + + team.logo_data = None + team.logo_content_type = None + 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=False, + ) diff --git a/backend/app/api/endpoints/onboarding.py b/backend/app/api/endpoints/onboarding.py new file mode 100644 index 00000000..fdb07cd8 --- /dev/null +++ b/backend/app/api/endpoints/onboarding.py @@ -0,0 +1,110 @@ +"""Onboarding status endpoints — track new-user checklist progress.""" + +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.assistant_chat import AssistantChat +from app.models.psa_connection import PsaConnection +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: + """Return onboarding checklist completion status for the current user.""" + + # created_flow — user owns at least 1 tree + created_flow_q = await db.execute( + select(func.count()).select_from(Tree).where(Tree.author_id == current_user.id).limit(1) + ) + created_flow = (created_flow_q.scalar() or 0) > 0 + + # ran_session — user has at least 1 session + ran_session_q = await db.execute( + select(func.count()).select_from(Session).where(Session.user_id == current_user.id).limit(1) + ) + ran_session = (ran_session_q.scalar() or 0) > 0 + + # exported_session — user has at least 1 session with exported=True + exported_q = await db.execute( + select(func.count()) + .select_from(Session) + .where(Session.user_id == current_user.id, Session.exported == True) # noqa: E712 + .limit(1) + ) + exported_session = (exported_q.scalar() or 0) > 0 + + # tried_ai_assistant — user has at least 1 assistant chat + ai_q = await db.execute( + select(func.count()) + .select_from(AssistantChat) + .where(AssistantChat.user_id == current_user.id) + .limit(1) + ) + tried_ai_assistant = (ai_q.scalar() or 0) > 0 + + # Team-dependent checks + is_team_user = current_user.team_id is not None + + invited_teammate = False + connected_psa = False + + if is_team_user: + # invited_teammate — team/account has more than 1 member + if current_user.account_id: + teammate_q = await db.execute( + select(func.count()) + .select_from(User) + .where( + User.account_id == current_user.account_id, + User.deleted_at.is_(None), + ) + ) + invited_teammate = (teammate_q.scalar() or 0) > 1 + + # connected_psa — account has at least 1 PSA connection + if current_user.account_id: + psa_q = await db.execute( + select(func.count()) + .select_from(PsaConnection) + .where(PsaConnection.account_id == current_user.account_id) + .limit(1) + ) + connected_psa = (psa_q.scalar() or 0) > 0 + + return OnboardingStatus( + created_flow=created_flow, + ran_session=ran_session, + exported_session=exported_session, + tried_ai_assistant=tried_ai_assistant, + invited_teammate=invited_teammate, + connected_psa=connected_psa, + is_team_user=is_team_user, + dismissed=current_user.onboarding_dismissed, + ) + + +@router.post("/onboarding-status/dismiss", response_model=OnboardingStatus) +async def dismiss_onboarding( + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_active_user)], +) -> OnboardingStatus: + """Dismiss the onboarding checklist for the current user.""" + current_user.onboarding_dismissed = True + await db.commit() + await db.refresh(current_user) + + # Return updated status (reuse the GET logic) + return await get_onboarding_status(db=db, current_user=current_user) diff --git a/backend/app/api/endpoints/sessions.py b/backend/app/api/endpoints/sessions.py index e0e303a2..ea4375f1 100644 --- a/backend/app/api/endpoints/sessions.py +++ b/backend/app/api/endpoints/sessions.py @@ -391,18 +391,51 @@ async def export_session( detail="You don't have access to this session" ) + # PDF export — separate path with binary response + if export_options.format == "pdf": + from app.services.export_service import generate_pdf_export + from fastapi.responses import Response + pdf_bytes = await generate_pdf_export(session, export_options, db) + + if session.completed_at: + session.exported = True + await db.commit() + + return Response( + content=pdf_bytes, + media_type="application/pdf", + headers={"Content-Disposition": f'attachment; filename="session-export-{session_id}.pdf"'}, + ) + + # Query supporting data for non-PDF formats + from app.models.supporting_data import SessionSupportingData + sd_result = await db.execute( + select(SessionSupportingData) + .where(SessionSupportingData.session_id == session_id) + .order_by(SessionSupportingData.sort_order) + ) + supporting_data_items = [ + { + "label": sd.label, + "data_type": sd.data_type, + "content": sd.content, + "content_type": sd.content_type, + } + for sd in sd_result.scalars().all() + ] + # Generate export based on format if export_options.format == "markdown": - content = generate_markdown_export(session, export_options) + content = generate_markdown_export(session, export_options, supporting_data=supporting_data_items) media_type = "text/markdown" elif export_options.format == "html": - content = generate_html_export(session, export_options) + content = generate_html_export(session, export_options, supporting_data=supporting_data_items) media_type = "text/html" elif export_options.format == "psa": - content = generate_psa_export(session, export_options) + content = generate_psa_export(session, export_options, supporting_data=supporting_data_items) media_type = "text/plain" else: # text - content = generate_text_export(session, export_options) + content = generate_text_export(session, export_options, supporting_data=supporting_data_items) media_type = "text/plain" # Resolve variables in export output diff --git a/backend/app/api/endpoints/supporting_data.py b/backend/app/api/endpoints/supporting_data.py new file mode 100644 index 00000000..b7d0a33a --- /dev/null +++ b/backend/app/api/endpoints/supporting_data.py @@ -0,0 +1,201 @@ +import base64 +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import get_db +from app.api.deps import get_current_active_user +from app.models import User +from app.models.session import Session +from app.models.supporting_data import SessionSupportingData +from app.schemas.supporting_data import ( + SupportingDataCreate, + SupportingDataUpdate, + SupportingDataResponse, +) + +router = APIRouter(prefix="/sessions", tags=["supporting-data"]) + +MAX_ITEMS_PER_SESSION = 20 +MAX_TEXT_SNIPPET_CHARS = 50_000 +MAX_SCREENSHOT_RAW_BYTES = 2 * 1024 * 1024 # 2MB + + +async def _check_session_access(user: User, session: Session, db: AsyncSession) -> None: + """Verify user has access to the session (owner, team admin, or super admin).""" + 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") + + +async def _get_session_or_404(session_id: UUID, db: AsyncSession) -> Session: + """Fetch session by ID or raise 404.""" + result = await db.execute(select(Session).where(Session.id == session_id)) + session = result.scalar_one_or_none() + if not session: + raise HTTPException(status_code=404, detail="Session not found") + return session + + +@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: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_active_user), +): + """Add a supporting data item (text snippet or screenshot) to a session.""" + session = await _get_session_or_404(session_id, db) + await _check_session_access(current_user, session, db) + + # Check item limit + count_result = await db.execute( + select(func.count()).select_from(SessionSupportingData).where( + SessionSupportingData.session_id == session_id + ) + ) + current_count = count_result.scalar() or 0 + if current_count >= MAX_ITEMS_PER_SESSION: + raise HTTPException( + status_code=400, + detail=f"Maximum {MAX_ITEMS_PER_SESSION} supporting data items per session", + ) + + # Validate content size based on type + if data.data_type == "text_snippet": + if len(data.content) > MAX_TEXT_SNIPPET_CHARS: + raise HTTPException( + status_code=400, + detail=f"Text snippet exceeds maximum {MAX_TEXT_SNIPPET_CHARS} characters", + ) + elif data.data_type == "screenshot": + try: + raw_bytes = base64.b64decode(data.content) + except Exception: + raise HTTPException(status_code=400, detail="Invalid base64 content for screenshot") + if len(raw_bytes) > MAX_SCREENSHOT_RAW_BYTES: + raise HTTPException( + status_code=400, + detail=f"Screenshot exceeds maximum {MAX_SCREENSHOT_RAW_BYTES // (1024 * 1024)}MB raw size", + ) + + # Auto-increment sort_order + max_order_result = await db.execute( + select(func.max(SessionSupportingData.sort_order)).where( + SessionSupportingData.session_id == session_id + ) + ) + max_order = max_order_result.scalar() + 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 item + + +@router.get( + "/{session_id}/supporting-data", + response_model=list[SupportingDataResponse], +) +async def list_supporting_data( + session_id: UUID, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_active_user), +): + """List all supporting data items for a session, ordered by sort_order.""" + session = await _get_session_or_404(session_id, db) + 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) + ) + return result.scalars().all() + + +@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: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_active_user), +): + """Update a supporting data item's label or content.""" + session = await _get_session_or_404(session_id, db) + await _check_session_access(current_user, session, db) + + result = await db.execute( + select(SessionSupportingData).where( + SessionSupportingData.id == item_id, + SessionSupportingData.session_id == session_id, + ) + ) + item = result.scalar_one_or_none() + if not item: + 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 + + await db.commit() + await db.refresh(item) + + return item + + +@router.delete( + "/{session_id}/supporting-data/{item_id}", + status_code=status.HTTP_204_NO_CONTENT, +) +async def delete_supporting_data( + session_id: UUID, + item_id: UUID, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_active_user), +): + """Remove a supporting data item from a session.""" + session = await _get_session_or_404(session_id, db) + await _check_session_access(current_user, session, db) + + result = await db.execute( + select(SessionSupportingData).where( + SessionSupportingData.id == item_id, + SessionSupportingData.session_id == session_id, + ) + ) + item = result.scalar_one_or_none() + if not item: + raise HTTPException(status_code=404, detail="Supporting data item not found") + + await db.delete(item) + await db.commit() diff --git a/backend/app/api/router.py b/backend/app/api/router.py index 3bc9afde..e857bd20 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -18,6 +18,9 @@ from app.api.endpoints import kb_accelerator from app.api.endpoints import beta_signup from app.api.endpoints import scripts from app.api.endpoints import integrations +from app.api.endpoints import onboarding +from app.api.endpoints import branding +from app.api.endpoints import supporting_data api_router = APIRouter() @@ -61,3 +64,6 @@ api_router.include_router(kb_accelerator.router) api_router.include_router(beta_signup.router) api_router.include_router(scripts.router) api_router.include_router(integrations.router) +api_router.include_router(onboarding.router) +api_router.include_router(branding.router) +api_router.include_router(supporting_data.router) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 1938cdf0..b7854b0b 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -39,6 +39,7 @@ from .script_template import ScriptCategory, ScriptTemplate, ScriptGeneration from .psa_connection import PsaConnection from .psa_post_log import PsaPostLog from .psa_member_mapping import PsaMemberMapping +from .supporting_data import SessionSupportingData __all__ = [ "User", @@ -92,4 +93,5 @@ __all__ = [ "PsaConnection", "PsaPostLog", "PsaMemberMapping", + "SessionSupportingData", ] diff --git a/backend/app/models/session.py b/backend/app/models/session.py index bbab74cf..c191572b 100644 --- a/backend/app/models/session.py +++ b/backend/app/models/session.py @@ -82,6 +82,7 @@ class Session(Base): assigned_to: Mapped[Optional["User"]] = relationship("User", foreign_keys=[assigned_to_id]) attachments: Mapped[list["Attachment"]] = relationship("Attachment", back_populates="session") shares: Mapped[list["SessionShare"]] = relationship("SessionShare", back_populates="session", cascade="all, delete-orphan") + supporting_data = relationship("SessionSupportingData", back_populates="session", cascade="all, delete-orphan", order_by="SessionSupportingData.sort_order") # PSA ticket link psa_ticket_id: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) diff --git a/backend/app/models/supporting_data.py b/backend/app/models/supporting_data.py new file mode 100644 index 00000000..ea04cd91 --- /dev/null +++ b/backend/app/models/supporting_data.py @@ -0,0 +1,25 @@ +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") diff --git a/backend/app/models/team.py b/backend/app/models/team.py index 299c9cb9..098444dd 100644 --- a/backend/app/models/team.py +++ b/backend/app/models/team.py @@ -1,7 +1,7 @@ import uuid from datetime import datetime, timezone -from typing import TYPE_CHECKING -from sqlalchemy import String, DateTime +from typing import Optional, TYPE_CHECKING +from sqlalchemy import String, DateTime, Text from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.dialects.postgresql import UUID from app.core.database import Base @@ -23,6 +23,12 @@ class Team(Base): default=uuid.uuid4 ) name: Mapped[str] = mapped_column(String(255), nullable=False) + + # 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) + created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) diff --git a/backend/app/models/user.py b/backend/app/models/user.py index f0c3f3f6..c7d566d7 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -1,7 +1,7 @@ import uuid from datetime import datetime, timezone from typing import Optional, TYPE_CHECKING -from sqlalchemy import String, DateTime, ForeignKey, Boolean, CheckConstraint +from sqlalchemy import String, DateTime, ForeignKey, Boolean, CheckConstraint, Text from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.dialects.postgresql import UUID from app.core.database import Base @@ -73,6 +73,15 @@ class User(Base): job_title: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) timezone: Mapped[str] = mapped_column(String(100), nullable=False, default="UTC", server_default="UTC") avatar_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) + + # 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) + email_verified_at: Mapped[Optional[datetime]] = mapped_column( DateTime(timezone=True), nullable=True ) diff --git a/backend/app/schemas/branding.py b/backend/app/schemas/branding.py new file mode 100644 index 00000000..bd6baeaf --- /dev/null +++ b/backend/app/schemas/branding.py @@ -0,0 +1,14 @@ +from typing import Optional +from pydantic import BaseModel + + +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 diff --git a/backend/app/schemas/onboarding.py b/backend/app/schemas/onboarding.py new file mode 100644 index 00000000..d21647b5 --- /dev/null +++ b/backend/app/schemas/onboarding.py @@ -0,0 +1,12 @@ +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 diff --git a/backend/app/schemas/session.py b/backend/app/schemas/session.py index 58f4e2df..2a764711 100644 --- a/backend/app/schemas/session.py +++ b/backend/app/schemas/session.py @@ -106,7 +106,7 @@ class SessionResponse(BaseModel): class SessionExport(BaseModel): - format: str = Field(default="markdown", pattern="^(text|markdown|html|psa)$") + format: str = Field(default="markdown", pattern="^(text|markdown|html|psa|pdf)$") include_timestamps: bool = True include_tree_info: bool = True # Phase A diff --git a/backend/app/schemas/supporting_data.py b/backend/app/schemas/supporting_data.py new file mode 100644 index 00000000..7469fab8 --- /dev/null +++ b/backend/app/schemas/supporting_data.py @@ -0,0 +1,30 @@ +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) + 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} diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index 496c46e9..a48df65b 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -46,6 +46,7 @@ class UserResponse(UserBase): role: str = "engineer" account_id: Optional[UUID] = None account_role: Optional[str] = None + team_id: Optional[UUID] = None is_super_admin: bool = False is_active: bool = True must_change_password: bool = False diff --git a/backend/app/services/export_service.py b/backend/app/services/export_service.py index 8654eb89..7d3d8091 100644 --- a/backend/app/services/export_service.py +++ b/backend/app/services/export_service.py @@ -1,11 +1,13 @@ """ Session export generators for ResolutionFlow. -Provides markdown, plain text, HTML, and PSA/ticket note export formatters +Provides markdown, plain text, HTML, PDF, and PSA/ticket note export formatters for troubleshooting sessions. """ import html +import os from datetime import datetime +from pathlib import Path from typing import Any from app.models.session import Session @@ -169,7 +171,7 @@ def _escape_markdown_table(value: str) -> str: return value.replace("|", "\\|").replace("\n", " ") -def generate_markdown_export(session: Session, options: SessionExport) -> str: +def generate_markdown_export(session: Session, options: SessionExport, supporting_data: list[dict] | None = None) -> str: """Generate markdown export.""" if _is_procedural_session(session): return _generate_procedural_markdown(session, options) @@ -259,6 +261,22 @@ def generate_markdown_export(session: Session, options: SessionExport) -> str: lines.append(f"*{decision['timestamp']}*") lines.append("") + # Supporting Data + if supporting_data: + lines.append("---") + lines.append("") + lines.append("## Supporting Data") + lines.append("") + for sd in supporting_data: + lines.append(f"### {sd['label']}") + if sd["data_type"] == "text_snippet": + lines.append("```") + lines.append(sd["content"]) + lines.append("```") + else: + lines.append(f"[Screenshot: {sd['label']}]") + lines.append("") + # Resolution / Outcome Notes _raw_notes = getattr(session, 'outcome_notes', None) outcome_notes = _raw_notes if isinstance(_raw_notes, str) else '' @@ -284,7 +302,7 @@ def generate_markdown_export(session: Session, options: SessionExport) -> str: return "\n".join(lines) -def generate_text_export(session: Session, options: SessionExport) -> str: +def generate_text_export(session: Session, options: SessionExport, supporting_data: list[dict] | None = None) -> str: """Generate plain text export.""" if _is_procedural_session(session): return _generate_procedural_text(session, options) @@ -359,6 +377,19 @@ def generate_text_export(session: Session, options: SessionExport) -> str: if duration_seconds is not None: lines.append(f" Duration: {_format_step_duration(duration_seconds)}") + # Supporting Data + if supporting_data: + lines.append("") + lines.append("SUPPORTING DATA") + lines.append("-" * 20) + for sd in supporting_data: + lines.append(f"\n {sd['label']}:") + if sd["data_type"] == "text_snippet": + for content_line in sd["content"].splitlines(): + lines.append(f" {content_line}") + else: + lines.append(f" [Screenshot: {sd['label']}]") + # Resolution _raw_notes = getattr(session, 'outcome_notes', None) outcome_notes = _raw_notes if isinstance(_raw_notes, str) else '' @@ -380,7 +411,7 @@ def generate_text_export(session: Session, options: SessionExport) -> str: return "\n".join(lines) -def generate_html_export(session: Session, options: SessionExport) -> str: +def generate_html_export(session: Session, options: SessionExport, supporting_data: list[dict] | None = None) -> str: """Generate HTML export.""" if _is_procedural_session(session): return _generate_procedural_html(session, options) @@ -470,6 +501,15 @@ def generate_html_export(session: Session, options: SessionExport) -> str: html_parts.append(f'

{html.escape(str(decision["timestamp"]))}

') html_parts.append('') + # Supporting Data + if supporting_data: + html_parts.append('

Supporting Data

') + for sd in supporting_data: + if sd["data_type"] == "text_snippet": + html_parts.append(f'

{html.escape(sd["label"])}

{html.escape(sd["content"])}
') + else: + html_parts.append(f'

{html.escape(sd["label"])}

{html.escape(sd[
') + # Resolution _raw_notes = getattr(session, 'outcome_notes', None) outcome_notes = _raw_notes if isinstance(_raw_notes, str) else '' @@ -488,7 +528,7 @@ def generate_html_export(session: Session, options: SessionExport) -> str: return "\n".join(html_parts) -def generate_psa_export(session: Session, options: SessionExport) -> str: +def generate_psa_export(session: Session, options: SessionExport, supporting_data: list[dict] | None = None) -> str: """Generate PSA/ticket note export optimized for ConnectWise and similar PSA tools.""" if _is_procedural_session(session): return _generate_procedural_psa(session, options) @@ -559,6 +599,19 @@ def generate_psa_export(session: Session, options: SessionExport) -> str: lines.append("No steps recorded.") lines.append("") + # Supporting Data + if supporting_data: + lines.append("--- SUPPORTING DATA ---") + for sd in supporting_data: + if sd["data_type"] == "text_snippet": + lines.append(f"## {sd['label']}") + lines.append("```") + lines.append(sd["content"]) + lines.append("```") + else: + lines.append(f"[Screenshot: {sd['label']}]") + lines.append("") + # Resolution — only for completed sessions if session.completed_at: lines.append("--- RESOLUTION ---") @@ -904,3 +957,188 @@ def _generate_procedural_psa(session: Session, options: SessionExport) -> str: lines.append(f"Duration: {_format_duration(session.started_at, session.completed_at)}") return "\n".join(lines) + + +async def generate_pdf_export(session: Session, options: SessionExport, db) -> bytes: + """Generate PDF export using WeasyPrint and a Jinja2 HTML template. + + Args: + session: The session to export. + options: Export options (redaction_mode, max_step_index, etc.). + db: Async database session for loading supporting data and branding. + + Returns: + PDF file contents as bytes. + """ + from jinja2 import Environment, FileSystemLoader + import weasyprint + from sqlalchemy import select as sa_select + + # Load Jinja2 template + template_dir = Path(__file__).resolve().parent.parent / "templates" + env = Environment(loader=FileSystemLoader(str(template_dir)), autoescape=True) + template = env.get_template("export_pdf.html") + + # Tree snapshot data + tree_snapshot = session.tree_snapshot or {} + flow_title = tree_snapshot.get("name", "Session Export") + tree_type = tree_snapshot.get("tree_type", "troubleshooting") + is_procedural = tree_type == "procedural" + report_type = "Procedure Report" if is_procedural else "Troubleshooting Report" + + # Branding — check team first, then user (solo pros) + logo_data = None + logo_content_type = None + company_name = None + + from app.models.user import User + user_result = await db.execute( + sa_select(User).where(User.id == session.user_id) + ) + user = user_result.scalar_one_or_none() + engineer_name = user.name if user else "Unknown" + + if user and user.team_id: + from app.models.team import Team + team_result = await db.execute( + sa_select(Team).where(Team.id == user.team_id) + ) + team = team_result.scalar_one_or_none() + if team: + logo_data = team.logo_data + logo_content_type = team.logo_content_type + company_name = team.company_display_name or team.name + elif user: + logo_data = user.logo_data + logo_content_type = user.logo_content_type + company_name = user.company_display_name + + has_custom_logo = bool(logo_data) + + # Build steps list from decisions + decisions = session.decisions or [] + if options.max_step_index is not None: + decisions = decisions[:options.max_step_index] + + steps = [] + for decision in decisions: + title = decision.get("question") or decision.get("action_performed", "Step") + answer = decision.get("answer", "") + notes = decision.get("notes", "") + duration_seconds = _get_step_duration_seconds(decision) + duration_str = _format_step_duration(duration_seconds) if duration_seconds is not None else None + + if is_procedural: + completed = answer == "completed" + decision_text = "Completed" if completed else ("Skipped" if answer else "") + else: + decision_text = answer + + steps.append({ + "title": title, + "decision": decision_text, + "notes": notes, + "duration": duration_str, + }) + + # Query supporting data + from app.models.supporting_data import SessionSupportingData + sd_result = await db.execute( + sa_select(SessionSupportingData) + .where(SessionSupportingData.session_id == session.id) + .order_by(SessionSupportingData.sort_order) + ) + supporting_data_rows = sd_result.scalars().all() + supporting_data = [ + { + "label": sd.label, + "data_type": sd.data_type, + "content": sd.content, + "content_type": sd.content_type, + } + for sd in supporting_data_rows + ] + + # Calculate duration and format outcome + duration = _format_duration(session.started_at, session.completed_at) + session_date = session.started_at.strftime("%Y-%m-%d %H:%M") + outcome_label = _get_outcome_label(session) or ("In Progress" if not session.completed_at else "Completed") + outcome_raw = getattr(session, "outcome", None) or "" + outcome_class = f"outcome-{outcome_raw}" if outcome_raw else "" + + # Build summary text + summary_text = "" + if options.include_summary: + summary_fields = _build_summary_fields(session) + parts = [] + for label, value in summary_fields.items(): + if value: + parts.append(f"{label.replace('_', ' ').title()}: {value}") + summary_text = "\n".join(parts) + + # Resolution / outcome notes as summary fallback + if not summary_text: + _raw_notes = getattr(session, "outcome_notes", None) + if isinstance(_raw_notes, str) and _raw_notes.strip(): + summary_text = _raw_notes.strip() + + generated_at = datetime.utcnow().strftime("%Y-%m-%d %H:%M UTC") + + # Variable resolution + session_vars = getattr(session, "session_variables", None) or {} + if session_vars: + from app.services.variable_service import resolve_variables + flow_title = resolve_variables(flow_title, session_vars) + summary_text = resolve_variables(summary_text, session_vars) + for step in steps: + step["title"] = resolve_variables(step["title"], session_vars) + if step["decision"]: + step["decision"] = resolve_variables(step["decision"], session_vars) + if step["notes"]: + step["notes"] = resolve_variables(step["notes"], session_vars) + for sd in supporting_data: + if sd["data_type"] == "text_snippet": + sd["content"] = resolve_variables(sd["content"], session_vars) + + # Apply redaction + if options.redaction_mode == "mask": + from app.services.redaction_service import apply_redaction_to_text + try: + flow_title, _ = apply_redaction_to_text(flow_title) + summary_text, _ = apply_redaction_to_text(summary_text) + for step in steps: + step["title"], _ = apply_redaction_to_text(step["title"]) + if step["decision"]: + step["decision"], _ = apply_redaction_to_text(step["decision"]) + if step["notes"]: + step["notes"], _ = apply_redaction_to_text(step["notes"]) + for sd in supporting_data: + if sd["data_type"] == "text_snippet": + sd["content"], _ = apply_redaction_to_text(sd["content"]) + except Exception: + pass # Redaction is best-effort for PDF + + # Render HTML + html_content = template.render( + report_type=report_type, + flow_title=flow_title, + logo_data=logo_data, + logo_content_type=logo_content_type or "image/png", + 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_label, + summary=summary_text, + steps=steps, + supporting_data=supporting_data, + generated_at=generated_at, + ) + + # Convert to PDF + pdf_bytes = weasyprint.HTML(string=html_content).write_pdf() + return pdf_bytes diff --git a/backend/app/templates/export_pdf.html b/backend/app/templates/export_pdf.html new file mode 100644 index 00000000..8a7b7c0a --- /dev/null +++ b/backend/app/templates/export_pdf.html @@ -0,0 +1,378 @@ + + + + + + + + + +
+
+
{{ report_type }}
+
{{ flow_title }}
+ {% if company_name %} +
{{ company_name }}
+ {% endif %} +
+ {% if logo_data %} + + {% endif %} +
+ + +
+
+ Engineer + {{ engineer_name or "N/A" }} +
+
+ Client + {{ client_name or "N/A" }} +
+
+ Ticket # + {{ ticket_number or "N/A" }} +
+
+ 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 %} +
{{ step.decision }}
+ {% endif %} + {% if step.notes %} +
{{ step.notes }}
+ {% endif %} + {% if step.duration %} +
{{ step.duration }}
+ {% endif %} +
+
+ {% endfor %} +
+ {% endif %} + + + {% if supporting_data %} +
+
Supporting Data
+ {% for item in supporting_data %} +
+
{{ item.label }}
+
+ {% if item.data_type == "screenshot" %} + {{ item.label }} + {% else %} +
{{ item.content }}
+ {% endif %} +
+
+ {% endfor %} +
+ {% endif %} + + + diff --git a/backend/requirements.txt b/backend/requirements.txt index 189c871e..b884f6e7 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -42,6 +42,10 @@ voyageai>=0.3.0 # Monitoring sentry-sdk[fastapi]>=2.54.0 +# PDF Export +weasyprint>=62.0 +jinja2>=3.1.0 + # Utilities python-dotenv==1.0.1 croniter>=2.0.0 diff --git a/backend/tests/test_branding.py b/backend/tests/test_branding.py new file mode 100644 index 00000000..c94c696a --- /dev/null +++ b/backend/tests/test_branding.py @@ -0,0 +1,249 @@ +"""Tests for team branding endpoints (logo upload + company display name).""" + +import uuid + +import pytest +from httpx import AsyncClient +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select + +from app.core.security import get_password_hash +from app.models.team import Team +from app.models.user import User + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +async def _create_team_with_admin( + test_db: AsyncSession, + client: AsyncClient, + *, + team_name: str = "Branding Test Team", +) -> tuple[dict, str, Team]: + """Create a team + team admin user. Returns (auth_headers, team_id_str, team).""" + team = Team(name=team_name) + test_db.add(team) + await test_db.flush() + + email = f"admin_{uuid.uuid4().hex[:8]}@test.com" + user = User( + email=email, + password_hash=get_password_hash("Password123!"), + name="Team Admin", + is_active=True, + team_id=team.id, + is_team_admin=True, + role="engineer", + ) + test_db.add(user) + await test_db.commit() + + resp = await client.post( + "/api/v1/auth/login/json", + json={"email": email, "password": "Password123!"}, + ) + assert resp.status_code == 200 + token = resp.json()["access_token"] + headers = {"Authorization": f"Bearer {token}"} + return headers, str(team.id), team + + +async def _create_team_member( + test_db: AsyncSession, + client: AsyncClient, + team: Team, + *, + is_team_admin: bool = False, +) -> dict: + """Create a regular team member. Returns auth_headers.""" + email = f"member_{uuid.uuid4().hex[:8]}@test.com" + user = User( + email=email, + password_hash=get_password_hash("Password123!"), + name="Team Member", + is_active=True, + team_id=team.id, + is_team_admin=is_team_admin, + role="engineer", + ) + test_db.add(user) + await test_db.commit() + + resp = await client.post( + "/api/v1/auth/login/json", + json={"email": email, "password": "Password123!"}, + ) + assert resp.status_code == 200 + token = resp.json()["access_token"] + return {"Authorization": f"Bearer {token}"} + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_get_branding_defaults(client: AsyncClient, test_db: AsyncSession): + """GET branding with no logo returns defaults (has_logo=False).""" + headers, team_id, _ = await _create_team_with_admin(test_db, client) + + resp = await client.get(f"/api/v1/teams/{team_id}/branding", headers=headers) + assert resp.status_code == 200 + data = resp.json() + assert data["has_logo"] is False + assert data["company_display_name"] is None + assert data["logo_content_type"] is None + + +@pytest.mark.asyncio +async def test_upload_logo_with_company_name(client: AsyncClient, test_db: AsyncSession): + """PATCH with valid PNG logo + company name succeeds.""" + headers, team_id, _ = await _create_team_with_admin(test_db, client) + + # 1x1 transparent PNG (67 bytes) + png_bytes = ( + b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01" + b"\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89" + b"\x00\x00\x00\nIDATx\x9cc\x00\x01\x00\x00\x05\x00\x01" + b"\r\n\xb4\x00\x00\x00\x00IEND\xaeB`\x82" + ) + + resp = await client.patch( + f"/api/v1/teams/{team_id}/branding", + headers=headers, + files={"logo": ("logo.png", png_bytes, "image/png")}, + data={"company_display_name": "Acme MSP"}, + ) + assert resp.status_code == 200, resp.text + data = resp.json() + assert data["has_logo"] is True + assert data["logo_content_type"] == "image/png" + assert data["company_display_name"] == "Acme MSP" + + +@pytest.mark.asyncio +async def test_upload_oversized_logo(client: AsyncClient, test_db: AsyncSession): + """PATCH with >2MB file returns 400.""" + headers, team_id, _ = await _create_team_with_admin(test_db, client) + + big_bytes = b"\x00" * (2 * 1024 * 1024 + 1) # 2MB + 1 byte + + resp = await client.patch( + f"/api/v1/teams/{team_id}/branding", + headers=headers, + files={"logo": ("big.png", big_bytes, "image/png")}, + ) + assert resp.status_code == 400 + assert "maximum size" in resp.json()["detail"].lower() + + +@pytest.mark.asyncio +async def test_upload_invalid_content_type(client: AsyncClient, test_db: AsyncSession): + """PATCH with application/pdf content type returns 400.""" + headers, team_id, _ = await _create_team_with_admin(test_db, client) + + resp = await client.patch( + f"/api/v1/teams/{team_id}/branding", + headers=headers, + files={"logo": ("doc.pdf", b"%PDF-fake", "application/pdf")}, + ) + assert resp.status_code == 400 + assert "content type" in resp.json()["detail"].lower() + + +@pytest.mark.asyncio +async def test_delete_logo(client: AsyncClient, test_db: AsyncSession): + """DELETE logo clears logo_data while keeping company_display_name.""" + headers, team_id, _ = await _create_team_with_admin(test_db, client) + + # Upload a logo + name first + png_bytes = b"\x89PNG\r\n\x1a\n" + b"\x00" * 50 + await client.patch( + f"/api/v1/teams/{team_id}/branding", + headers=headers, + files={"logo": ("logo.png", png_bytes, "image/png")}, + data={"company_display_name": "Keep This Name"}, + ) + + # Delete logo + resp = await client.delete( + f"/api/v1/teams/{team_id}/branding/logo", + headers=headers, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["has_logo"] is False + assert data["logo_content_type"] is None + assert data["company_display_name"] == "Keep This Name" + + +@pytest.mark.asyncio +async def test_non_admin_cannot_update(client: AsyncClient, test_db: AsyncSession): + """Regular team member (non-admin) cannot PATCH branding — returns 403.""" + admin_headers, team_id, team = await _create_team_with_admin(test_db, client) + member_headers = await _create_team_member(test_db, client, team) + + resp = await client.patch( + f"/api/v1/teams/{team_id}/branding", + headers=member_headers, + data={"company_display_name": "Should Fail"}, + ) + assert resp.status_code == 403 + + +@pytest.mark.asyncio +async def test_non_admin_cannot_delete_logo(client: AsyncClient, test_db: AsyncSession): + """Regular team member cannot DELETE logo — returns 403.""" + admin_headers, team_id, team = await _create_team_with_admin(test_db, client) + member_headers = await _create_team_member(test_db, client, team) + + resp = await client.delete( + f"/api/v1/teams/{team_id}/branding/logo", + headers=member_headers, + ) + assert resp.status_code == 403 + + +@pytest.mark.asyncio +async def test_non_member_cannot_read(client: AsyncClient, test_db: AsyncSession): + """User from a different team cannot GET branding — returns 403.""" + _, team_id, _ = await _create_team_with_admin(test_db, client, team_name="Team A") + other_headers, _, _ = await _create_team_with_admin(test_db, client, team_name="Team B") + + resp = await client.get( + f"/api/v1/teams/{team_id}/branding", + headers=other_headers, + ) + assert resp.status_code == 403 + + +@pytest.mark.asyncio +async def test_member_can_read_branding(client: AsyncClient, test_db: AsyncSession): + """Regular team member CAN read branding.""" + admin_headers, team_id, team = await _create_team_with_admin(test_db, client) + member_headers = await _create_team_member(test_db, client, team) + + resp = await client.get( + f"/api/v1/teams/{team_id}/branding", + headers=member_headers, + ) + assert resp.status_code == 200 + assert resp.json()["has_logo"] is False + + +@pytest.mark.asyncio +async def test_update_display_name_only(client: AsyncClient, test_db: AsyncSession): + """PATCH with only company_display_name (no logo) succeeds.""" + headers, team_id, _ = await _create_team_with_admin(test_db, client) + + resp = await client.patch( + f"/api/v1/teams/{team_id}/branding", + headers=headers, + data={"company_display_name": "Just A Name"}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["company_display_name"] == "Just A Name" + assert data["has_logo"] is False diff --git a/backend/tests/test_onboarding.py b/backend/tests/test_onboarding.py new file mode 100644 index 00000000..aa4f48d8 --- /dev/null +++ b/backend/tests/test_onboarding.py @@ -0,0 +1,72 @@ +"""Tests for onboarding status endpoints.""" + +import pytest + + +@pytest.mark.asyncio +async def test_onboarding_status_fresh_user(client, auth_headers): + """Fresh user should have all onboarding 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["invited_teammate"] is False + assert data["connected_psa"] is False + assert data["is_team_user"] is False + assert data["dismissed"] is False + + +@pytest.mark.asyncio +async def test_onboarding_dismiss(client, auth_headers): + """Dismiss endpoint should set dismissed to true.""" + # Verify starts as false + response = await client.get( + "/api/v1/users/onboarding-status", + headers=auth_headers, + ) + assert response.status_code == 200 + assert response.json()["dismissed"] is False + + # Dismiss + response = await client.post( + "/api/v1/users/onboarding-status/dismiss", + headers=auth_headers, + ) + assert response.status_code == 200 + assert response.json()["dismissed"] is True + + # Verify persisted + response = await client.get( + "/api/v1/users/onboarding-status", + headers=auth_headers, + ) + assert response.status_code == 200 + assert response.json()["dismissed"] is True + + +@pytest.mark.asyncio +async def test_onboarding_created_flow_after_tree_creation(client, auth_headers, test_tree): + """After creating a tree, created_flow should be true.""" + response = await client.get( + "/api/v1/users/onboarding-status", + headers=auth_headers, + ) + assert response.status_code == 200 + assert response.json()["created_flow"] is True + + +@pytest.mark.asyncio +async def test_onboarding_requires_auth(client): + """Unauthenticated requests should be rejected.""" + response = await client.get("/api/v1/users/onboarding-status") + assert response.status_code == 401 + + response = await client.post("/api/v1/users/onboarding-status/dismiss") + assert response.status_code == 401 diff --git a/backend/tests/test_pdf_export.py b/backend/tests/test_pdf_export.py new file mode 100644 index 00000000..da2666dc --- /dev/null +++ b/backend/tests/test_pdf_export.py @@ -0,0 +1,96 @@ +"""Tests for PDF export via WeasyPrint.""" + +import pytest +from httpx import AsyncClient + + +@pytest.mark.asyncio +class TestPDFExport: + """Test PDF export endpoint.""" + + async def test_export_pdf_returns_pdf_content( + self, client: AsyncClient, auth_headers: dict, test_tree: dict + ): + """Test that PDF export returns application/pdf content starting with %PDF.""" + # Create a session + create_response = await client.post( + "/api/v1/sessions", + json={"tree_id": test_tree["id"], "ticket_number": "PDF-001"}, + headers=auth_headers, + ) + assert create_response.status_code in (200, 201) + session_id = create_response.json()["id"] + + # Add a decision so there's content + await client.put( + f"/api/v1/sessions/{session_id}", + json={ + "decisions": [ + { + "node_id": "root", + "question": "Is this a test?", + "answer": "Yes", + "notes": "PDF export test", + "timestamp": "2026-03-17T10:00:00Z", + } + ] + }, + headers=auth_headers, + ) + + # Export as PDF + response = await client.post( + f"/api/v1/sessions/{session_id}/export", + json={"format": "pdf", "include_tree_info": True}, + headers=auth_headers, + ) + + assert response.status_code == 200 + assert response.headers["content-type"] == "application/pdf" + assert "session-export-" in response.headers.get("content-disposition", "") + # PDF files start with %PDF + assert response.content[:5] == b"%PDF-" + + async def test_export_pdf_with_no_supporting_data( + self, client: AsyncClient, auth_headers: dict, test_tree: dict + ): + """Test PDF export works when session has no supporting data.""" + create_response = await client.post( + "/api/v1/sessions", + json={"tree_id": test_tree["id"]}, + headers=auth_headers, + ) + assert create_response.status_code in (200, 201) + session_id = create_response.json()["id"] + + response = await client.post( + f"/api/v1/sessions/{session_id}/export", + json={"format": "pdf"}, + headers=auth_headers, + ) + + assert response.status_code == 200 + assert response.headers["content-type"] == "application/pdf" + assert response.content[:5] == b"%PDF-" + + async def test_existing_markdown_export_still_works( + self, client: AsyncClient, auth_headers: dict, test_tree: dict + ): + """Verify markdown export is unaffected by PDF addition.""" + create_response = await client.post( + "/api/v1/sessions", + json={"tree_id": test_tree["id"], "ticket_number": "MD-001"}, + headers=auth_headers, + ) + assert create_response.status_code in (200, 201) + session_id = create_response.json()["id"] + + response = await client.post( + f"/api/v1/sessions/{session_id}/export", + json={"format": "markdown", "include_tree_info": True}, + headers=auth_headers, + ) + + assert response.status_code == 200 + assert "text/markdown" in response.headers["content-type"] + assert "MD-001" in response.text diff --git a/backend/tests/test_supporting_data.py b/backend/tests/test_supporting_data.py new file mode 100644 index 00000000..dc5dd4db --- /dev/null +++ b/backend/tests/test_supporting_data.py @@ -0,0 +1,217 @@ +import base64 +import pytest +from httpx import AsyncClient + +pytestmark = pytest.mark.asyncio + + +@pytest.fixture +async def test_session(client: AsyncClient, auth_headers: dict, test_tree: dict): + """Create a test session from the test tree.""" + response = await client.post( + "/api/v1/sessions", + json={"tree_id": test_tree["id"]}, + headers=auth_headers, + ) + assert response.status_code == 201, f"Failed to create session: {response.text}" + return response.json() + + +# --- Create --- + + +async def test_create_text_snippet(client: AsyncClient, auth_headers: dict, test_session: dict): + """Create a text snippet supporting data item — returns 201.""" + response = await client.post( + f"/api/v1/sessions/{test_session['id']}/supporting-data", + json={ + "label": "Error log", + "data_type": "text_snippet", + "content": "NullReferenceException at line 42", + }, + headers=auth_headers, + ) + assert response.status_code == 201 + data = response.json() + assert data["label"] == "Error log" + assert data["data_type"] == "text_snippet" + assert data["content"] == "NullReferenceException at line 42" + assert data["sort_order"] == 1 + assert data["session_id"] == test_session["id"] + + +async def test_create_screenshot(client: AsyncClient, auth_headers: dict, test_session: dict): + """Create a screenshot supporting data item — returns 201.""" + # Small valid base64 content (a tiny PNG-like payload) + small_content = base64.b64encode(b"\x89PNG\r\n\x1a\n" + b"\x00" * 100).decode() + response = await client.post( + f"/api/v1/sessions/{test_session['id']}/supporting-data", + json={ + "label": "Error screenshot", + "data_type": "screenshot", + "content": small_content, + "content_type": "image/png", + }, + headers=auth_headers, + ) + assert response.status_code == 201 + data = response.json() + assert data["label"] == "Error screenshot" + assert data["data_type"] == "screenshot" + assert data["content_type"] == "image/png" + + +# --- List --- + + +async def test_list_items_in_sort_order(client: AsyncClient, auth_headers: dict, test_session: dict): + """List returns items ordered by sort_order.""" + # Create 3 items + for i in range(3): + resp = await client.post( + f"/api/v1/sessions/{test_session['id']}/supporting-data", + json={ + "label": f"Item {i}", + "data_type": "text_snippet", + "content": f"Content {i}", + }, + headers=auth_headers, + ) + assert resp.status_code == 201 + + response = await client.get( + f"/api/v1/sessions/{test_session['id']}/supporting-data", + headers=auth_headers, + ) + assert response.status_code == 200 + items = response.json() + assert len(items) == 3 + assert items[0]["label"] == "Item 0" + assert items[1]["label"] == "Item 1" + assert items[2]["label"] == "Item 2" + assert items[0]["sort_order"] < items[1]["sort_order"] < items[2]["sort_order"] + + +# --- Delete --- + + +async def test_delete_item(client: AsyncClient, auth_headers: dict, test_session: dict): + """Delete removes the item.""" + create_resp = await client.post( + f"/api/v1/sessions/{test_session['id']}/supporting-data", + json={ + "label": "To delete", + "data_type": "text_snippet", + "content": "Will be removed", + }, + headers=auth_headers, + ) + assert create_resp.status_code == 201 + item_id = create_resp.json()["id"] + + delete_resp = await client.delete( + f"/api/v1/sessions/{test_session['id']}/supporting-data/{item_id}", + headers=auth_headers, + ) + assert delete_resp.status_code == 204 + + # Verify it's gone + list_resp = await client.get( + f"/api/v1/sessions/{test_session['id']}/supporting-data", + headers=auth_headers, + ) + assert list_resp.status_code == 200 + assert len(list_resp.json()) == 0 + + +# --- Validation --- + + +async def test_exceed_20_item_limit(client: AsyncClient, auth_headers: dict, test_session: dict): + """Cannot exceed 20 items per session — returns 400.""" + for i in range(20): + resp = await client.post( + f"/api/v1/sessions/{test_session['id']}/supporting-data", + json={ + "label": f"Item {i}", + "data_type": "text_snippet", + "content": f"Content {i}", + }, + headers=auth_headers, + ) + assert resp.status_code == 201, f"Failed creating item {i}: {resp.text}" + + # 21st should fail + response = await client.post( + f"/api/v1/sessions/{test_session['id']}/supporting-data", + json={ + "label": "One too many", + "data_type": "text_snippet", + "content": "Should fail", + }, + headers=auth_headers, + ) + assert response.status_code == 400 + assert "20" in response.json()["detail"] + + +async def test_screenshot_exceeds_2mb(client: AsyncClient, auth_headers: dict, test_session: dict): + """Screenshot over 2MB raw (base64 decoded) — returns 400.""" + # Create content that decodes to > 2MB + large_raw = b"\x00" * (2 * 1024 * 1024 + 1) # 2MB + 1 byte + large_b64 = base64.b64encode(large_raw).decode() + + response = await client.post( + f"/api/v1/sessions/{test_session['id']}/supporting-data", + json={ + "label": "Large screenshot", + "data_type": "screenshot", + "content": large_b64, + }, + headers=auth_headers, + ) + assert response.status_code == 400 + assert "2MB" in response.json()["detail"] + + +async def test_text_snippet_over_50k_chars(client: AsyncClient, auth_headers: dict, test_session: dict): + """Text snippet over 50,000 characters — returns 400.""" + response = await client.post( + f"/api/v1/sessions/{test_session['id']}/supporting-data", + json={ + "label": "Huge text", + "data_type": "text_snippet", + "content": "x" * 50_001, + }, + headers=auth_headers, + ) + assert response.status_code == 400 + assert "50000" in response.json()["detail"] + + +# --- Update --- + + +async def test_patch_update_label(client: AsyncClient, auth_headers: dict, test_session: dict): + """PATCH to update label returns updated item.""" + create_resp = await client.post( + f"/api/v1/sessions/{test_session['id']}/supporting-data", + json={ + "label": "Original label", + "data_type": "text_snippet", + "content": "Some content", + }, + headers=auth_headers, + ) + assert create_resp.status_code == 201 + item_id = create_resp.json()["id"] + + patch_resp = await client.patch( + f"/api/v1/sessions/{test_session['id']}/supporting-data/{item_id}", + json={"label": "Updated label"}, + headers=auth_headers, + ) + assert patch_resp.status_code == 200 + data = patch_resp.json() + assert data["label"] == "Updated label" + assert data["content"] == "Some content" # unchanged 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) diff --git a/docs/superpowers/specs/2026-03-16-empty-states-onboarding-exports-design.md b/docs/superpowers/specs/2026-03-16-empty-states-onboarding-exports-design.md new file mode 100644 index 00000000..3a9b8c92 --- /dev/null +++ b/docs/superpowers/specs/2026-03-16-empty-states-onboarding-exports-design.md @@ -0,0 +1,440 @@ +# Empty States, Onboarding & Professional Exports — Design Spec + +> **Date:** 2026-03-16 +> **Product:** ResolutionFlow +> **Approach:** Bottom-up (foundation → empty states → onboarding → exports) + +--- + +## Purpose + +Make ResolutionFlow feel polished and professional by eliminating dead-end empty pages, guiding new users through setup, and providing client-ready PDF exports that MSPs can hand directly to customers. + +--- + +## Scope + +### In Scope + +1. Illustrative empty states across 8 pages with benefit-oriented copy and "Learn more" guide links +2. Onboarding starter checklist widget on QuickStartPage (solo and team variants) +3. Team branding settings (logo upload, company display name) +4. PDF export via WeasyPrint with branded templates +5. Supporting data capture during sessions (text snippets + screenshots) +6. 7 in-app user guides linked from empty states +7. Tests: backend integration, frontend unit, Playwright e2e + +### Out of Scope + +- Bring-your-own-storage (S3/Azure) for supporting data — future feature +- Full file attachments beyond screenshots +- Removing "Powered by ResolutionFlow" footer (potential premium tier) +- Multi-browser Playwright matrix + +--- + +## 1. Empty States + +### Component Upgrade + +Extend the existing `EmptyState.tsx` component to support the illustrative style: + +- **SVG illustration slot** — optional prop, renders a brand-colored line-art illustration above the title +- **Benefit-oriented description** — explains what the page does and why it matters, not just "no data" +- **Primary CTA button** — navigates to the action that populates the page +- **Secondary "Learn more" link** — navigates to the relevant in-app guide + +### Pages (8 total) + +| Page | Title | Description | CTA | Guide Link | +|------|-------|-------------|-----|------------| +| Flow Library (no flows) | Build your first troubleshooting flow | Flows guide your team through proven resolution paths, capturing every decision along the way. | Create a Flow | `/guides/creating-flows` | +| Flow Library (no filter results) | No flows match your filters | Try adjusting your search or filters. | Clear Filters | — | +| Analytics (My/Team) | Track your troubleshooting performance | Analytics show resolution times, common paths, and team efficiency. Data appears automatically as you complete sessions. | Run Your First Session | `/guides/understanding-analytics` | +| Session History (empty) | Your session history will appear here | Every troubleshooting session is recorded with decisions, timing, and outcomes — ready for export or review. | Start a Session | `/guides/running-sessions` | +| Integrations | Connect your PSA for seamless workflows | Link ConnectWise or other PSA tools to pull ticket context into sessions and push documentation back automatically. | Connect Integration | `/guides/psa-setup` | +| Step Library (empty) | Build a reusable step library | Save common troubleshooting steps once, reuse them across flows. Keeps your team consistent and saves build time. | Browse Steps | `/guides/step-library` | +| Script Library (empty) | Automate with script templates | Pre-built and custom scripts your team can reference during sessions. PowerShell, bash, and more. | Explore Templates | `/guides/script-templates` | +| My Shares (empty) | Share session results with your team | Create shareable links to completed sessions for knowledge sharing and client communication. | View Sessions | `/guides/sharing-sessions` | + +### Illustrations + +Simple SVG line art using the cyan brand color palette (`#06b6d4` → `#22d3ee`). Each page gets a unique illustration relevant to its content. Lightweight — no complex animations or heavy graphics. + +### Visual Style + +- Container: centered content within the page's existing layout +- Illustration: 60-80px height, `opacity: 0.4` → `0.7` range (needs to be visible on `#101114` dark background) +- Title: `text-foreground`, `text-lg` (18px), `font-semibold` (matches existing `EmptyState.tsx`) +- Description: `text-muted-foreground`, 13px, max-width ~400px for readability +- CTA: `bg-gradient-brand` primary button style +- Learn more: `text-muted-foreground` with `→` arrow, hover brightens + +--- + +## 2. Onboarding Starter Checklist + +### Location + +Dismissible `.glass-card` widget on QuickStartPage, positioned below the greeting and above the stats/activity sections. + +### Visibility Rules + +- Shows for users who haven't dismissed it and haven't completed all items +- Auto-hides with a brief "You're all set!" state once all items are checked, then disappears +- Dismissible at any time via "×" button +- Dismissed/completed state stored in a new `onboarding_dismissed` Boolean column on the `users` table (requires migration). Not using JSON — a simple column is clearer and queryable. +- Never reappears once dismissed or completed + +### Completion Tracking + +No new database table. A single API endpoint queries existing data to determine completion status. + +**Endpoint:** `GET /api/v1/users/onboarding-status` + +**Response:** + +```json +{ + "created_flow": true, + "ran_session": false, + "exported_session": false, + "tried_ai_assistant": false, + "invited_teammate": false, + "connected_psa": false, + "is_team_user": true, + "dismissed": false +} +``` + +**Completion queries:** + +| Item | Condition | +|------|-----------| +| `created_flow` | User owns at least 1 tree | +| `ran_session` | User has at least 1 session | +| `exported_session` | User has at least 1 session with `exported=True` | +| `tried_ai_assistant` | User has at least 1 assistant chat | +| `invited_teammate` | Team has more than 1 member | +| `connected_psa` | Team has at least 1 PSA connection | + +**Dismiss endpoint:** `POST /api/v1/users/onboarding-status/dismiss` — sets `onboarding_dismissed=True` on the user record. + +### Checklist Variants + +**Solo pro (4 items):** + +1. Create your first flow → navigates to Flow Library +2. Run your first session → navigates to Flow Library +3. Export a session → navigates to Session History +4. Try the AI assistant → navigates to AI Chat + +**Team admin (5 items):** + +1. Create your first flow → navigates to Flow Library +2. Invite a team member → navigates to Team Settings +3. Run your first session → navigates to Flow Library +4. Connect a PSA integration → navigates to Integrations +5. Export a session → navigates to Session History + +### Visual Design + +- `.glass-card` container with `border-radius: 16px` +- Cyan progress bar at top showing completion (e.g., "2 of 5 complete") +- Section label: "Getting Started" in `font-label text-[0.625rem] uppercase tracking-[0.1em]` +- Each item: checkbox (auto-checked with cyan fill when complete) + label + subtle navigation arrow +- Completed items: muted text with cyan checkmark +- Uncompleted items: `text-foreground` with hover highlight, clickable to navigate + +--- + +## 3. Team Branding & Logo Upload + +### Location + +New "Branding" section on the existing Team Settings page. Team admin only. Solo pros get a simpler version on their Account Settings page. + +### Fields + +- **Company logo** — image upload (PNG, JPG, or SVG, max 2MB) +- **Company display name** — text field, falls back to team name if empty +- **Logo preview** — shows how the logo will appear on exports + +### Backend + +**New columns on `teams` table:** + +- `logo_data` — Text, base64-encoded image data, nullable +- `logo_content_type` — String (e.g., `image/png`), nullable +- `company_display_name` — String, nullable (falls back to `team.name`) + +**Endpoints:** + +- `PATCH /api/v1/teams/{team_id}/branding` — upload logo (multipart form) + display name. Team admin only. +- `GET /api/v1/teams/{team_id}/branding` — retrieve logo data + display name. Any team member. +- `DELETE /api/v1/teams/{team_id}/branding/logo` — remove logo. Team admin only. + +**Validation:** + +- File size: max 2MB +- Content type: `image/png`, `image/jpeg`, `image/svg+xml` +- Solo pros: branding columns (`logo_data`, `logo_content_type`, `company_display_name`) added directly to the `users` table. Same schema as teams. Solo pros without a team still get branded exports. + +### Why Base64 in DB + +Logos are small (< 2MB raw, ~2.67MB base64-encoded) and there's one per team/user. The 2MB validation limit applies to the raw uploaded file size (before base64 encoding). Avoids S3/file storage dependency entirely. Easy to migrate to object storage later when BYOS is implemented for supporting data. + +--- + +## 4. PDF Export via WeasyPrint + +### Backend + +**New dependency:** `weasyprint` in `requirements.txt` + +**System dependencies (required by WeasyPrint):** +- `libpango1.0-dev`, `libcairo2-dev`, `libgdk-pixbuf2.0-dev`, `libffi-dev` +- Must be added to the Railway Dockerfile via `apt-get install` +- Local dev: install via system package manager (`apt-get` on Ubuntu/Debian) +- CI: add to the e2e job's setup step + +**Export service changes:** + +- New `generate_pdf()` method in `export_service.py` +- Renders a Jinja2 HTML template with session data + branding, then converts to PDF via WeasyPrint +- Template location: `backend/app/templates/export_pdf.html` + +**Existing endpoint change:** + +- `POST /sessions/{session_id}/export` gains `format: "pdf"` option +- Update `SessionExport` schema: change `format` field pattern from `^(text|markdown|html|psa)$` to `^(text|markdown|html|psa|pdf)$` +- PDF format returns `Response(content=pdf_bytes, media_type="application/pdf")` with `Content-Disposition: attachment; filename="session-export-{id}.pdf"` header (different return type from the existing `PlainTextResponse` used by other formats — endpoint must branch on format) +- Non-PDF formats continue returning `PlainTextResponse` as before + +### PDF Template Structure + +Matches the approved mockup layout: + +1. **Header** — Report type label (e.g., "Troubleshooting Report"), flow title, MSP logo or ResolutionFlow logo, company name +2. **Metadata grid** — Engineer, Client, Ticket #, Date, Duration, Outcome (3×2 grid) +3. **Summary** — AI-generated session summary (from existing feature) +4. **Troubleshooting Path** — Visual timeline with cyan step dots, step titles, and decisions at each node. Final resolution step uses green dot. +5. **Supporting Data** — Labeled text snippets (rendered as code blocks) + embedded screenshot images +6. **Footer** — Generation timestamp (left) + "Powered by ResolutionFlow" (right) + +### CSS/Styling + +- White background, dark text (print-optimized) +- Cyan accent color (`#06b6d4`) for section borders, timeline dots, and branding +- `@page` rules for margins, header/footer positioning +- Page break before Supporting Data section if content runs long +- `break-inside: avoid` on individual supporting data items +- JetBrains Mono for code/command output blocks + +### Frontend Changes + +- Add "PDF" to the format selector in `ExportPreviewModal` +- PDF option triggers a direct file download (no textarea preview — PDFs aren't editable inline). The modal should switch to a "download-only" mode when PDF is selected: hide the textarea, show a download button with loading state. The format selector stays visible for switching between formats. +- Show a loading spinner while PDF generates server-side +- Existing formats (markdown, text, HTML, PSA) continue to work as before with the textarea preview + +### Branding Logic + +1. If team has a logo → use team logo + company display name in header, "Powered by ResolutionFlow" in footer +2. If no team logo → use ResolutionFlow logo in header, no "Powered by" footer (it's already the primary brand) +3. Solo pro with logo → same as team logo behavior + +--- + +## 5. Supporting Data Capture + +### Database + +**New table: `session_supporting_data`** + +| Column | Type | Notes | +|--------|------|-------| +| `id` | UUID | Primary key | +| `session_id` | UUID | FK to sessions | +| `label` | String(255) | User-provided label (e.g., "Port Scan Output") | +| `data_type` | Enum | `text_snippet` or `screenshot` | +| `content` | Text | Raw text or base64-encoded image | +| `content_type` | String(50) | Nullable. e.g., `image/png` for screenshots | +| `sort_order` | Integer | Display ordering | +| `created_at` | DateTime(timezone=True) | Auto-set | +| `updated_at` | DateTime(timezone=True) | Auto-set, auto-update | + +### API Endpoints + +- `POST /api/v1/sessions/{session_id}/supporting-data` — add an item (label, type, content). Returns created item. +- `GET /api/v1/sessions/{session_id}/supporting-data` — list all items for a session, ordered by `sort_order`. +- `PATCH /api/v1/sessions/{session_id}/supporting-data/{id}` — update label or content. +- `DELETE /api/v1/sessions/{session_id}/supporting-data/{id}` — remove an item. + +### Validation + +- Image size: max 2MB per screenshot (keeps DB growth manageable — at 20 items × 2.67MB base64 = ~53MB worst case per session) +- Text snippet: max 50,000 characters +- Max 20 items per session +- Only the session owner or team admins can add/delete +- Monitor DB size growth in production — if supporting data exceeds expectations, prioritize BYOS migration + +### Session Runner UI + +- **"Add Supporting Data" button** — positioned near the existing notes input in both troubleshooting and procedural session runners +- **Add modal** with two tabs/options: + - **Text Snippet** — label input + multiline textarea + - **Screenshot** — label input + drag-and-drop zone / file picker + clipboard paste (`Ctrl+V`) support +- **Supporting data list** — collapsible section below session notes showing added items: + - Each item: type icon (code bracket for text, image icon for screenshot) + label + preview (truncated text or thumbnail) + delete button + - No reordering in v1 — items display in creation order + +### Export Integration + +Supporting data is included in all export formats: + +| Format | Text Snippets | Screenshots | +|--------|--------------|-------------| +| Markdown | Labeled fenced code blocks | `![label](data:image/...)` or `[Screenshot: label]` | +| Plain Text | Labeled indented blocks | `[Screenshot: {label}]` placeholder | +| HTML | `
` blocks with labels | `` tags with base64 src |
+| PSA | Labeled code blocks (markdown) | `[Screenshot: {label}]` placeholder |
+| PDF | Styled code blocks matching mockup | Embedded images |
+
+---
+
+## 6. User Guides
+
+### Route
+
+`/guides/:slug` — new frontend route inside the authenticated app shell.
+
+### Implementation
+
+- Markdown files stored in `frontend/src/content/guides/`
+- Written as React components in `frontend/src/pages/guides/` (avoids `react-markdown` dependency for only 7 short pages). Each guide is a simple functional component using existing typography classes.
+- Displayed in a `.glass-card-static` container within the standard app shell layout
+- Simple breadcrumb: "Guides → {title}"
+- Images/illustrations stored in `frontend/public/guides/` and referenced via absolute paths
+- Unknown slugs show a "Guide not found" empty state with a link back to the dashboard
+
+### Guides (7)
+
+| Slug | Title | Content Covers |
+|------|-------|---------------|
+| `creating-flows` | Creating Flows | Manual flow creation, AI-assisted creation, flow types (troubleshooting, procedural, maintenance), basic editor usage |
+| `understanding-analytics` | Understanding Analytics | What each metric means, how data populates over time, team vs personal views |
+| `running-sessions` | Running Sessions | Starting a session, navigating decisions, adding notes, adding supporting data, completing and exporting |
+| `psa-setup` | Connecting Your PSA | ConnectWise setup walkthrough, where to find API credentials, what the integration enables |
+| `step-library` | Using the Step Library | Browsing shared steps, adding steps to flows, creating reusable steps |
+| `script-templates` | Script Templates | Browsing templates, using scripts during sessions, creating custom templates |
+| `sharing-sessions` | Sharing Sessions | Creating share links, public vs account-only access, revoking shares |
+
+### Guide Content Style
+
+- Concise — each guide should be 300-600 words
+- Task-oriented — "How to do X" structure, not reference documentation
+- Include relevant screenshots/illustrations where helpful
+- End with a CTA that links back to the relevant feature page
+
+---
+
+## 7. Testing
+
+### Backend Integration Tests (pytest)
+
+**Onboarding status:**
+- Returns correct booleans for a fresh user (all false)
+- Returns `created_flow: true` after user creates a tree
+- Returns `ran_session: true` after user starts a session
+- Returns correct `is_team_user` flag
+- Dismiss endpoint sets `dismissed: true`
+
+**Team branding:**
+- Upload logo — stores base64, returns success
+- Upload oversized file — returns 400
+- Upload invalid content type — returns 400
+- Retrieve branding — returns logo data + display name
+- Delete logo — clears logo data
+- Non-admin cannot update branding — returns 403
+
+**Supporting data:**
+- Create text snippet — stores and returns item
+- Create screenshot — stores base64 and returns item
+- List items — returns in sort order
+- Delete item — removes from DB
+- Exceed 20 item limit — returns 400
+- Exceed 2MB screenshot — returns 400
+- Non-owner cannot add to session — returns 403
+
+**PDF export:**
+- Generate PDF — returns valid PDF bytes with correct content type
+- PDF includes branding when team has logo
+- PDF uses ResolutionFlow defaults when no team logo
+- PDF includes supporting data items
+- PDF handles session with no supporting data gracefully
+
+### Frontend Unit Tests (Vitest)
+
+- `EmptyState` component — renders illustration, title, description, CTA, and learn more link with correct props
+- `EmptyState` without optional props — renders without illustration or learn more link
+- Onboarding checklist — renders correct items for solo vs team user
+- Onboarding checklist — completed items show cyan checkmark and muted style
+- Onboarding checklist — dismiss button calls dismiss endpoint
+- Export format selector — includes PDF option
+- Export modal — PDF selection triggers download behavior instead of textarea preview
+
+### Playwright E2E Tests
+
+- **Empty state flow** — log in as fresh user, navigate to Flow Library, verify empty state renders with CTA and "Learn more" link
+- **Onboarding checklist** — log in as fresh user, verify checklist visible on dashboard, create a flow, verify checklist item updates
+- **PDF export** — complete a session, navigate to session detail, select PDF format, verify download triggers
+- **Guide page** — click "Learn more" from an empty state, verify guide page loads with content
+
+These Playwright tests focus on happy paths only — one representative flow per feature area.
+
+---
+
+## Implementation Order (Bottom-Up)
+
+### PR 1: Backend Foundation
+- `onboarding_dismissed` column on `users` table + migration
+- Team branding columns (`logo_data`, `logo_content_type`, `company_display_name`) on `teams` table + migration
+- Solo pro branding columns on `users` table + migration (can combine with onboarding migration)
+- Branding CRUD endpoints
+- Supporting data table + migration
+- Supporting data CRUD endpoints (POST, GET, PATCH, DELETE)
+- Onboarding status endpoint + dismiss endpoint
+- WeasyPrint dependency + system deps in Dockerfile + PDF generation in export service
+- Update `SessionExport` schema format pattern to include `pdf`
+- Backend tests for all of the above
+
+### PR 2: Empty States + Guides
+- Upgrade `EmptyState.tsx` component
+- Roll out across 8 pages
+- Create 7 markdown guides
+- Add `/guides/:slug` route
+- Frontend unit tests for EmptyState
+
+### PR 3: Onboarding Checklist
+- QuickStartPage checklist widget
+- Solo vs team variant logic
+- Dismiss and auto-complete behavior
+- Frontend unit tests
+- Playwright test for checklist
+
+### PR 4: PDF Export + Supporting Data UI
+- Supporting data capture in session runner
+- PDF format option in ExportPreviewModal
+- Team branding section on Team Settings page
+- Playwright tests for export and empty states
+
+---
+
+## Future Considerations
+
+- **BYOS (Bring Your Own Storage):** Allow MSPs to configure their own S3/Azure blob storage for supporting data. Removes our storage burden and addresses data sovereignty.
+- **Premium branding tier:** Option to remove "Powered by ResolutionFlow" footer for higher-tier plans.
+- **Full file attachments:** Extend supporting data to accept arbitrary file types (logs, configs, CSVs) once object storage is in place.
+- **Export templates:** Let teams customize the PDF template layout, colors, and sections included.
+- **Onboarding expansion:** Feature tours, tooltips, and contextual help beyond the starter checklist.
diff --git a/frontend/src/api/branding.ts b/frontend/src/api/branding.ts
new file mode 100644
index 00000000..37387efb
--- /dev/null
+++ b/frontend/src/api/branding.ts
@@ -0,0 +1,23 @@
+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`)
+}
diff --git a/frontend/src/api/onboarding.ts b/frontend/src/api/onboarding.ts
new file mode 100644
index 00000000..4f54e687
--- /dev/null
+++ b/frontend/src/api/onboarding.ts
@@ -0,0 +1,21 @@
+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')
+}
diff --git a/frontend/src/api/supportingData.ts b/frontend/src/api/supportingData.ts
new file mode 100644
index 00000000..0dc36546
--- /dev/null
+++ b/frontend/src/api/supportingData.ts
@@ -0,0 +1,39 @@
+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}`)
+}
diff --git a/frontend/src/components/common/EmptyState.tsx b/frontend/src/components/common/EmptyState.tsx
index 22e4a266..b917eb6b 100644
--- a/frontend/src/components/common/EmptyState.tsx
+++ b/frontend/src/components/common/EmptyState.tsx
@@ -1,23 +1,51 @@
 import type { 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, title, description, action, className }: EmptyStateProps) {
+export function EmptyState({
+  icon,
+  illustration,
+  title,
+  description,
+  action,
+  learnMoreLink,
+  learnMoreText = 'Learn more',
+  className,
+}: EmptyStateProps) {
   return (
     
- {icon &&
{icon}
} + {illustration && ( +
+ {illustration} +
+ )} + {!illustration && icon && ( +
{icon}
+ )}

{title}

{description && ( -

{description}

+

{description}

)} {action &&
{action}
} + {learnMoreLink && ( + + {learnMoreText} → + + )}
) } diff --git a/frontend/src/components/common/EmptyStateIllustrations.tsx b/frontend/src/components/common/EmptyStateIllustrations.tsx new file mode 100644 index 00000000..5ea17830 --- /dev/null +++ b/frontend/src/components/common/EmptyStateIllustrations.tsx @@ -0,0 +1,130 @@ +/** + * SVG illustrations for EmptyState components. + * Each uses the brand cyan palette (#06b6d4 / #22d3ee) at low opacity. + * ViewBox: 80x60, simple line art style. + */ + +export function FlowIllustration() { + return ( + + {/* Root node */} + + {/* Branches */} + + + {/* Left child */} + + {/* Right child */} + + {/* Leaf branches */} + + + + + + ) +} + +export function AnalyticsIllustration() { + return ( + + {/* Bars */} + + + + + {/* Baseline */} + + + ) +} + +export function SessionIllustration() { + return ( + + {/* Card 1 */} + + + + {/* Card 2 */} + + + + {/* Card 3 */} + + + + + ) +} + +export function IntegrationIllustration() { + return ( + + {/* Left box */} + + + + {/* Right box */} + + + + {/* Dashed arrows */} + + + {/* Arrow tips */} + + + + ) +} + +export function StepLibraryIllustration() { + return ( + + {/* List items */} + + + + + + + + + + ) +} + +export function ScriptIllustration() { + return ( + + {/* Terminal window */} + + {/* Title bar */} + + + + {/* Code lines */} + + + + + + ) +} + +export function ShareIllustration() { + return ( + + {/* Center node */} + + {/* Top-right node */} + + {/* Bottom-right node */} + + {/* Connecting lines */} + + + + ) +} diff --git a/frontend/src/components/common/__tests__/EmptyState.test.tsx b/frontend/src/components/common/__tests__/EmptyState.test.tsx new file mode 100644 index 00000000..83b81a3c --- /dev/null +++ b/frontend/src/components/common/__tests__/EmptyState.test.tsx @@ -0,0 +1,92 @@ +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' + +function renderWithRouter(ui: React.ReactElement) { + return render({ui}) +} + +describe('EmptyState', () => { + it('renders title and description', () => { + renderWithRouter( + + ) + + expect(screen.getByText('No items found')).toBeInTheDocument() + expect(screen.getByText('Try adjusting your filters.')).toBeInTheDocument() + }) + + it('renders illustration when provided', () => { + const { container } = renderWithRouter( + } + /> + ) + + const svg = container.querySelector('svg') + expect(svg).toBeInTheDocument() + }) + + it('renders action button', () => { + renderWithRouter( + Create New} + /> + ) + + expect(screen.getByRole('button', { name: 'Create New' })).toBeInTheDocument() + }) + + it('renders learn more link with correct href', () => { + renderWithRouter( + + ) + + const link = screen.getByText(/Learn more/i) + expect(link).toBeInTheDocument() + expect(link).toHaveAttribute('href', '/guides/creating-flows') + }) + + it('renders custom learn more text', () => { + renderWithRouter( + + ) + + expect(screen.getByText(/View guide/i)).toBeInTheDocument() + }) + + it('renders without optional props', () => { + renderWithRouter() + + expect(screen.getByText('Just a title')).toBeInTheDocument() + expect(screen.queryByRole('button')).not.toBeInTheDocument() + expect(screen.queryByRole('link')).not.toBeInTheDocument() + }) + + it('prefers illustration over icon when both provided', () => { + const { container } = renderWithRouter( + icon} + illustration={} + /> + ) + + expect(container.querySelector('svg')).toBeInTheDocument() + expect(screen.queryByTestId('icon')).not.toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/dashboard/OnboardingChecklist.tsx b/frontend/src/components/dashboard/OnboardingChecklist.tsx new file mode 100644 index 00000000..f4552204 --- /dev/null +++ b/frontend/src/components/dashboard/OnboardingChecklist.tsx @@ -0,0 +1,160 @@ +import { useState, useEffect } from 'react' +import { useNavigate } from 'react-router-dom' +import { Check, X, ChevronRight } from 'lucide-react' +import { cn } from '@/lib/utils' +import { getOnboardingStatus, dismissOnboarding } from '@/api/onboarding' +import type { 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 navigate = useNavigate() + const [status, setStatus] = useState(null) + const [dismissed, setDismissed] = useState(false) + const [allComplete, setAllComplete] = useState(false) + + useEffect(() => { + getOnboardingStatus() + .then(setStatus) + .catch(() => { + // Silently fail — don't show checklist if endpoint unavailable + }) + }, []) + + const items = status?.is_team_user ? TEAM_ITEMS : SOLO_ITEMS + const completedCount = status + ? items.filter((item) => status[item.key]).length + : 0 + const totalCount = items.length + const isAllDone = completedCount === totalCount && status !== null + + useEffect(() => { + if (isAllDone) { + const timer = setTimeout(() => setAllComplete(true), 2000) + return () => clearTimeout(timer) + } + }, [isAllDone]) + + // Don't render if dismissed, fully complete, or not loaded yet + if (!status || status.dismissed || dismissed || allComplete) return null + + const progressPercent = totalCount > 0 ? (completedCount / totalCount) * 100 : 0 + + const handleDismiss = async () => { + setDismissed(true) + try { + await dismissOnboarding() + } catch { + // Already hidden locally + } + } + + return ( +
+ {/* Progress bar */} +
+
+
+ +
+ {/* Header */} +
+
+

+ Getting Started +

+

+ {isAllDone ? ( + You're all set! + ) : ( + + {completedCount} + {' '}of {totalCount} complete + + )} +

+
+ +
+ + {/* Checklist items */} +
    + {items.map((item) => { + const done = status[item.key] + return ( +
  • + +
  • + ) + })} +
+
+
+ ) +} diff --git a/frontend/src/components/scripts/ScriptTemplateList.tsx b/frontend/src/components/scripts/ScriptTemplateList.tsx index e0f32476..f1dc21da 100644 --- a/frontend/src/components/scripts/ScriptTemplateList.tsx +++ b/frontend/src/components/scripts/ScriptTemplateList.tsx @@ -1,5 +1,7 @@ -import { FileCode, Search } from 'lucide-react' +import { Search } from 'lucide-react' import { useScriptGeneratorStore } from '@/store/scriptGeneratorStore' +import { EmptyState } from '@/components/common/EmptyState' +import { ScriptIllustration } from '@/components/common/EmptyStateIllustrations' import { TemplateCard } from './TemplateCard' interface Props { @@ -52,10 +54,13 @@ export function ScriptTemplateList({ inputValue, onClearSearch, onConfigure }: P ) } return ( -
- -

No templates found

-
+ } + title="Automate with script templates" + description="Pre-built and custom scripts your team can reference during sessions. PowerShell, bash, and more." + learnMoreLink="/guides/script-templates" + className="px-4" + /> ) } diff --git a/frontend/src/components/session/AddSupportingDataModal.tsx b/frontend/src/components/session/AddSupportingDataModal.tsx new file mode 100644 index 00000000..23a85883 --- /dev/null +++ b/frontend/src/components/session/AddSupportingDataModal.tsx @@ -0,0 +1,263 @@ +import { useState, useRef, useCallback } from 'react' +import { Code2, ImageIcon, Upload } from 'lucide-react' +import { Modal } from '@/components/common/Modal' +import { Button } from '@/components/ui/Button' +import { cn } from '@/lib/utils' +import { createSupportingData } from '@/api/supportingData' +import { toast } from '@/lib/toast' + +interface AddSupportingDataModalProps { + isOpen: boolean + onClose: () => void + sessionId: string + onAdded: () => void +} + +type TabType = 'text_snippet' | 'screenshot' + +const MAX_FILE_SIZE = 2 * 1024 * 1024 // 2MB + +export function AddSupportingDataModal({ isOpen, onClose, sessionId, onAdded }: AddSupportingDataModalProps) { + const [activeTab, setActiveTab] = useState('text_snippet') + const [label, setLabel] = useState('') + const [textContent, setTextContent] = useState('') + const [imageBase64, setImageBase64] = useState(null) + const [imageContentType, setImageContentType] = useState(null) + const [imageFileName, setImageFileName] = useState(null) + const [isSubmitting, setIsSubmitting] = useState(false) + const [error, setError] = useState(null) + const fileInputRef = useRef(null) + + const resetForm = () => { + setLabel('') + setTextContent('') + setImageBase64(null) + setImageContentType(null) + setImageFileName(null) + setError(null) + setActiveTab('text_snippet') + } + + const handleClose = () => { + resetForm() + onClose() + } + + const processFile = useCallback((file: File) => { + if (file.size > MAX_FILE_SIZE) { + setError('File must be under 2MB') + return + } + if (!['image/png', 'image/jpeg', 'image/svg+xml'].includes(file.type)) { + setError('Only PNG, JPEG, and SVG files are supported') + return + } + setError(null) + setImageFileName(file.name) + setImageContentType(file.type) + + const reader = new FileReader() + reader.onload = () => { + const result = reader.result as string + // Strip the data:... prefix to get raw base64 + const base64 = result.includes(',') ? result.split(',')[1] : result + setImageBase64(base64) + } + reader.readAsDataURL(file) + }, []) + + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (file) processFile(file) + } + + const handlePaste = useCallback((e: React.ClipboardEvent) => { + const items = e.clipboardData?.items + if (!items) return + for (const item of items) { + if (item.type.startsWith('image/')) { + e.preventDefault() + const file = item.getAsFile() + if (file) processFile(file) + return + } + } + }, [processFile]) + + const handleSubmit = async () => { + if (!label.trim()) { + setError('Label is required') + return + } + + if (activeTab === 'text_snippet' && !textContent.trim()) { + setError('Content is required') + return + } + + if (activeTab === 'screenshot' && !imageBase64) { + setError('Please select or paste an image') + return + } + + setIsSubmitting(true) + setError(null) + try { + await createSupportingData(sessionId, { + label: label.trim(), + data_type: activeTab, + content: activeTab === 'text_snippet' ? textContent : imageBase64!, + content_type: activeTab === 'screenshot' ? (imageContentType ?? undefined) : undefined, + }) + toast.success('Supporting data added') + onAdded() + handleClose() + } catch (err) { + console.error('Failed to add supporting data:', err) + setError('Failed to save. Please try again.') + } finally { + setIsSubmitting(false) + } + } + + return ( + + {/* Tabs */} +
+ + +
+ + {/* Label */} +
+ + setLabel(e.target.value)} + placeholder={activeTab === 'text_snippet' ? 'e.g. Error log output' : 'e.g. Blue screen photo'} + className={cn( + 'w-full rounded-md border border-border bg-card px-3 py-2 text-sm', + 'text-foreground placeholder:text-muted-foreground', + 'focus:border-[rgba(6,182,212,0.3)] focus:outline-hidden focus:ring-1 focus:ring-primary/20' + )} + /> +
+ + {/* Text Snippet Tab Content */} + {activeTab === 'text_snippet' && ( +
+ +