feat: empty states, onboarding checklist, PDF exports, and supporting data #114

Merged
chihlasm merged 16 commits from feat/backend-foundation-empty-states-exports into main 2026-03-18 00:42:30 +00:00
55 changed files with 6555 additions and 113 deletions

6
.gitignore vendored
View File

@@ -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

View File

@@ -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

View File

@@ -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')

View File

@@ -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")

View File

@@ -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,
)

View File

@@ -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)

View File

@@ -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

View File

@@ -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()

View File

@@ -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)

View File

@@ -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",
]

View File

@@ -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)

View File

@@ -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")

View File

@@ -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)

View File

@@ -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
)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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}

View File

@@ -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

View File

@@ -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'<p class="timestamp">{html.escape(str(decision["timestamp"]))}</p>')
html_parts.append('</div>')
# Supporting Data
if supporting_data:
html_parts.append('<h2>Supporting Data</h2>')
for sd in supporting_data:
if sd["data_type"] == "text_snippet":
html_parts.append(f'<div class="supporting-item"><h3>{html.escape(sd["label"])}</h3><pre>{html.escape(sd["content"])}</pre></div>')
else:
html_parts.append(f'<div class="supporting-item"><h3>{html.escape(sd["label"])}</h3><img src="data:{html.escape(sd.get("content_type") or "image/png")};base64,{sd["content"]}" alt="{html.escape(sd["label"])}"></div>')
# 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

View File

@@ -0,0 +1,378 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<style>
{% if has_custom_logo %}
@page {
size: A4;
margin: 2cm;
@bottom-right {
content: "Powered by ResolutionFlow";
font-size: 8pt;
color: #999;
}
@bottom-left {
content: "Generated {{ generated_at }}";
font-size: 8pt;
color: #999;
}
}
{% else %}
@page {
size: A4;
margin: 2cm;
@bottom-left {
content: "Generated {{ generated_at }}";
font-size: 8pt;
color: #999;
}
}
{% endif %}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
color: #1a1a2e;
background: #ffffff;
font-size: 10pt;
line-height: 1.5;
}
/* --- Header --- */
.header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 3px solid #06b6d4;
}
.header-left {
flex: 1;
}
.report-type {
font-size: 8pt;
text-transform: uppercase;
letter-spacing: 0.1em;
color: #06b6d4;
font-weight: 600;
margin-bottom: 4px;
}
.flow-title {
font-size: 18pt;
font-weight: 700;
color: #1a1a2e;
margin-bottom: 4px;
}
.company-name {
font-size: 10pt;
color: #666;
}
.header-logo {
flex-shrink: 0;
margin-left: 24px;
}
.header-logo img {
max-width: 120px;
max-height: 60px;
object-fit: contain;
}
/* --- Metadata Grid --- */
.metadata-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 12px;
margin-bottom: 24px;
padding: 16px;
background: #f8fafc;
border-radius: 8px;
border: 1px solid #e2e8f0;
}
.meta-item {
display: flex;
flex-direction: column;
}
.meta-label {
font-size: 7pt;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #94a3b8;
font-weight: 600;
margin-bottom: 2px;
}
.meta-value {
font-size: 10pt;
color: #1a1a2e;
font-weight: 500;
}
.outcome-resolved {
color: #059669;
font-weight: 700;
}
.outcome-escalated {
color: #d97706;
font-weight: 700;
}
.outcome-workaround {
color: #2563eb;
font-weight: 700;
}
.outcome-unresolved {
color: #dc2626;
font-weight: 700;
}
/* --- Section Headers --- */
.section-title {
font-size: 12pt;
font-weight: 700;
color: #1a1a2e;
margin-bottom: 12px;
padding-bottom: 6px;
border-bottom: 2px solid #06b6d4;
}
/* --- Summary --- */
.summary {
margin-bottom: 24px;
}
.summary-text {
font-size: 10pt;
color: #334155;
line-height: 1.6;
white-space: pre-wrap;
}
/* --- Troubleshooting Path Timeline --- */
.timeline {
margin-bottom: 24px;
}
.timeline-step {
display: flex;
align-items: flex-start;
margin-bottom: 8px;
position: relative;
padding-left: 24px;
}
.timeline-step::before {
content: "";
position: absolute;
left: 5px;
top: 6px;
width: 10px;
height: 10px;
background: #06b6d4;
border-radius: 50%;
}
.timeline-step::after {
content: "";
position: absolute;
left: 9px;
top: 18px;
width: 2px;
height: calc(100% + 0px);
background: #e2e8f0;
}
.timeline-step:last-child::after {
display: none;
}
.step-content {
flex: 1;
padding-bottom: 8px;
}
.step-title {
font-size: 10pt;
font-weight: 600;
color: #1a1a2e;
}
.step-decision {
font-size: 9pt;
color: #06b6d4;
font-weight: 500;
margin-top: 2px;
}
.step-notes {
font-size: 9pt;
color: #64748b;
font-style: italic;
margin-top: 2px;
}
.step-duration {
font-size: 8pt;
color: #94a3b8;
margin-top: 2px;
}
/* --- Supporting Data --- */
.supporting-data {
margin-bottom: 24px;
}
.supporting-item {
margin-bottom: 16px;
break-inside: avoid;
border: 1px solid #e2e8f0;
border-radius: 8px;
overflow: hidden;
}
.supporting-item-label {
font-size: 9pt;
font-weight: 600;
color: #1a1a2e;
padding: 8px 12px;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
}
.supporting-item-content {
padding: 12px;
}
.code-block {
font-family: "JetBrains Mono", "Fira Code", "Consolas", monospace;
font-size: 8pt;
line-height: 1.5;
background: #f1f5f9;
padding: 12px;
border-radius: 4px;
white-space: pre-wrap;
word-break: break-all;
color: #334155;
}
.screenshot-img {
max-width: 100%;
height: auto;
border-radius: 4px;
}
</style>
</head>
<body>
<!-- Header -->
<div class="header">
<div class="header-left">
<div class="report-type">{{ report_type }}</div>
<div class="flow-title">{{ flow_title }}</div>
{% if company_name %}
<div class="company-name">{{ company_name }}</div>
{% endif %}
</div>
{% if logo_data %}
<div class="header-logo">
<img src="data:{{ logo_content_type }};base64,{{ logo_data }}" alt="Logo">
</div>
{% endif %}
</div>
<!-- Metadata Grid -->
<div class="metadata-grid">
<div class="meta-item">
<span class="meta-label">Engineer</span>
<span class="meta-value">{{ engineer_name or "N/A" }}</span>
</div>
<div class="meta-item">
<span class="meta-label">Client</span>
<span class="meta-value">{{ client_name or "N/A" }}</span>
</div>
<div class="meta-item">
<span class="meta-label">Ticket #</span>
<span class="meta-value">{{ ticket_number or "N/A" }}</span>
</div>
<div class="meta-item">
<span class="meta-label">Date</span>
<span class="meta-value">{{ session_date }}</span>
</div>
<div class="meta-item">
<span class="meta-label">Duration</span>
<span class="meta-value">{{ duration }}</span>
</div>
<div class="meta-item">
<span class="meta-label">Outcome</span>
<span class="meta-value {{ outcome_class }}">{{ outcome_display }}</span>
</div>
</div>
<!-- Summary -->
{% if summary %}
<div class="summary">
<div class="section-title">Summary</div>
<div class="summary-text">{{ summary }}</div>
</div>
{% endif %}
<!-- Troubleshooting Path -->
{% if steps %}
<div class="timeline">
<div class="section-title">Troubleshooting Path</div>
{% for step in steps %}
<div class="timeline-step">
<div class="step-content">
<div class="step-title">{{ loop.index }}. {{ step.title }}</div>
{% if step.decision %}
<div class="step-decision">{{ step.decision }}</div>
{% endif %}
{% if step.notes %}
<div class="step-notes">{{ step.notes }}</div>
{% endif %}
{% if step.duration %}
<div class="step-duration">{{ step.duration }}</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% endif %}
<!-- Supporting Data -->
{% if supporting_data %}
<div class="supporting-data">
<div class="section-title">Supporting Data</div>
{% for item in supporting_data %}
<div class="supporting-item">
<div class="supporting-item-label">{{ item.label }}</div>
<div class="supporting-item-content">
{% if item.data_type == "screenshot" %}
<img class="screenshot-img" src="data:{{ item.content_type or 'image/png' }};base64,{{ item.content }}" alt="{{ item.label }}">
{% else %}
<div class="code-block">{{ item.content }}</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% endif %}
</body>
</html>

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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 | `<pre>` blocks with labels | `<img>` 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.

View File

@@ -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<BrandingInfo> {
const response = await apiClient.get(`/teams/${teamId}/branding`)
return response.data
}
export async function updateBranding(teamId: string, formData: FormData): Promise<BrandingInfo> {
const response = await apiClient.patch(`/teams/${teamId}/branding`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
return response.data
}
export async function deleteLogo(teamId: string): Promise<void> {
await apiClient.delete(`/teams/${teamId}/branding/logo`)
}

View File

@@ -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<OnboardingStatus> {
const response = await apiClient.get('/users/onboarding-status')
return response.data
}
export async function dismissOnboarding(): Promise<void> {
await apiClient.post('/users/onboarding-status/dismiss')
}

View File

@@ -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<SupportingDataItem[]> {
const response = await apiClient.get(`/sessions/${sessionId}/supporting-data`)
return response.data
}
export async function createSupportingData(
sessionId: string,
data: { label: string; data_type: string; content: string; content_type?: string }
): Promise<SupportingDataItem> {
const response = await apiClient.post(`/sessions/${sessionId}/supporting-data`, data)
return response.data
}
export async function updateSupportingData(
sessionId: string,
itemId: string,
data: { label?: string; content?: string }
): Promise<SupportingDataItem> {
const response = await apiClient.patch(`/sessions/${sessionId}/supporting-data/${itemId}`, data)
return response.data
}
export async function deleteSupportingData(sessionId: string, itemId: string): Promise<void> {
await apiClient.delete(`/sessions/${sessionId}/supporting-data/${itemId}`)
}

View File

@@ -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 (
<div className={cn('flex flex-col items-center justify-center py-12 text-center', className)}>
{icon && <div className="mb-4 text-muted-foreground">{icon}</div>}
{illustration && (
<div className="mb-6 opacity-60">
{illustration}
</div>
)}
{!illustration && icon && (
<div className="mb-4 text-muted-foreground">{icon}</div>
)}
<h3 className="text-lg font-semibold text-foreground">{title}</h3>
{description && (
<p className="mt-1 max-w-sm text-sm text-muted-foreground">{description}</p>
<p className="mt-2 max-w-sm text-sm text-muted-foreground">{description}</p>
)}
{action && <div className="mt-4">{action}</div>}
{learnMoreLink && (
<Link
to={learnMoreLink}
className="mt-3 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
{learnMoreText} &rarr;
</Link>
)}
</div>
)
}

View File

@@ -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 (
<svg width="80" height="60" viewBox="0 0 80 60" fill="none" xmlns="http://www.w3.org/2000/svg">
{/* Root node */}
<circle cx="40" cy="10" r="6" fill="rgba(6,182,212,0.15)" stroke="#06b6d4" strokeWidth="1.5" />
{/* Branches */}
<line x1="40" y1="16" x2="20" y2="34" stroke="#06b6d4" strokeWidth="1.5" />
<line x1="40" y1="16" x2="60" y2="34" stroke="#06b6d4" strokeWidth="1.5" />
{/* Left child */}
<circle cx="20" cy="38" r="5" fill="rgba(6,182,212,0.15)" stroke="#22d3ee" strokeWidth="1.5" />
{/* Right child */}
<circle cx="60" cy="38" r="5" fill="rgba(6,182,212,0.15)" stroke="#22d3ee" strokeWidth="1.5" />
{/* Leaf branches */}
<line x1="20" y1="43" x2="12" y2="52" stroke="#22d3ee" strokeWidth="1" />
<line x1="20" y1="43" x2="28" y2="52" stroke="#22d3ee" strokeWidth="1" />
<circle cx="12" cy="54" r="3" fill="rgba(34,211,238,0.15)" stroke="#22d3ee" strokeWidth="1" />
<circle cx="28" cy="54" r="3" fill="rgba(34,211,238,0.15)" stroke="#22d3ee" strokeWidth="1" />
</svg>
)
}
export function AnalyticsIllustration() {
return (
<svg width="80" height="60" viewBox="0 0 80 60" fill="none" xmlns="http://www.w3.org/2000/svg">
{/* Bars */}
<rect x="12" y="38" width="10" height="16" rx="2" fill="rgba(6,182,212,0.15)" stroke="#06b6d4" strokeWidth="1.5" />
<rect x="26" y="28" width="10" height="26" rx="2" fill="rgba(6,182,212,0.15)" stroke="#22d3ee" strokeWidth="1.5" />
<rect x="40" y="20" width="10" height="34" rx="2" fill="rgba(6,182,212,0.15)" stroke="#06b6d4" strokeWidth="1.5" />
<rect x="54" y="10" width="10" height="44" rx="2" fill="rgba(6,182,212,0.15)" stroke="#22d3ee" strokeWidth="1.5" />
{/* Baseline */}
<line x1="8" y1="56" x2="72" y2="56" stroke="#06b6d4" strokeWidth="1" opacity="0.5" />
</svg>
)
}
export function SessionIllustration() {
return (
<svg width="80" height="60" viewBox="0 0 80 60" fill="none" xmlns="http://www.w3.org/2000/svg">
{/* Card 1 */}
<rect x="12" y="8" width="56" height="12" rx="3" fill="rgba(6,182,212,0.15)" stroke="#06b6d4" strokeWidth="1.5" />
<circle cx="22" cy="14" r="2" fill="#06b6d4" />
<line x1="28" y1="14" x2="56" y2="14" stroke="#06b6d4" strokeWidth="1" opacity="0.5" />
{/* Card 2 */}
<rect x="12" y="24" width="56" height="12" rx="3" fill="rgba(6,182,212,0.15)" stroke="#22d3ee" strokeWidth="1.5" />
<circle cx="22" cy="30" r="2" fill="#22d3ee" />
<line x1="28" y1="30" x2="52" y2="30" stroke="#22d3ee" strokeWidth="1" opacity="0.5" />
{/* Card 3 */}
<rect x="12" y="40" width="56" height="12" rx="3" fill="rgba(6,182,212,0.15)" stroke="#06b6d4" strokeWidth="1.5" />
<circle cx="22" cy="46" r="2" fill="#06b6d4" />
<line x1="28" y1="46" x2="48" y2="46" stroke="#06b6d4" strokeWidth="1" opacity="0.5" />
</svg>
)
}
export function IntegrationIllustration() {
return (
<svg width="80" height="60" viewBox="0 0 80 60" fill="none" xmlns="http://www.w3.org/2000/svg">
{/* Left box */}
<rect x="6" y="18" width="22" height="24" rx="4" fill="rgba(6,182,212,0.15)" stroke="#06b6d4" strokeWidth="1.5" />
<line x1="12" y1="26" x2="22" y2="26" stroke="#06b6d4" strokeWidth="1" opacity="0.6" />
<line x1="12" y1="30" x2="20" y2="30" stroke="#06b6d4" strokeWidth="1" opacity="0.4" />
{/* Right box */}
<rect x="52" y="18" width="22" height="24" rx="4" fill="rgba(6,182,212,0.15)" stroke="#22d3ee" strokeWidth="1.5" />
<line x1="58" y1="26" x2="68" y2="26" stroke="#22d3ee" strokeWidth="1" opacity="0.6" />
<line x1="58" y1="30" x2="66" y2="30" stroke="#22d3ee" strokeWidth="1" opacity="0.4" />
{/* Dashed arrows */}
<line x1="30" y1="26" x2="50" y2="26" stroke="#06b6d4" strokeWidth="1.5" strokeDasharray="3 2" />
<line x1="50" y1="34" x2="30" y2="34" stroke="#22d3ee" strokeWidth="1.5" strokeDasharray="3 2" />
{/* Arrow tips */}
<path d="M48 23 L52 26 L48 29" stroke="#06b6d4" strokeWidth="1.5" fill="none" />
<path d="M32 31 L28 34 L32 37" stroke="#22d3ee" strokeWidth="1.5" fill="none" />
</svg>
)
}
export function StepLibraryIllustration() {
return (
<svg width="80" height="60" viewBox="0 0 80 60" fill="none" xmlns="http://www.w3.org/2000/svg">
{/* List items */}
<circle cx="18" cy="14" r="3" fill="rgba(6,182,212,0.15)" stroke="#06b6d4" strokeWidth="1.5" />
<line x1="26" y1="14" x2="62" y2="14" stroke="#06b6d4" strokeWidth="1.5" opacity="0.5" />
<circle cx="18" cy="27" r="3" fill="rgba(6,182,212,0.15)" stroke="#22d3ee" strokeWidth="1.5" />
<line x1="26" y1="27" x2="58" y2="27" stroke="#22d3ee" strokeWidth="1.5" opacity="0.5" />
<circle cx="18" cy="40" r="3" fill="rgba(6,182,212,0.15)" stroke="#06b6d4" strokeWidth="1.5" />
<line x1="26" y1="40" x2="54" y2="40" stroke="#06b6d4" strokeWidth="1.5" opacity="0.5" />
<circle cx="18" cy="53" r="3" fill="rgba(34,211,238,0.15)" stroke="#22d3ee" strokeWidth="1.5" />
<line x1="26" y1="53" x2="50" y2="53" stroke="#22d3ee" strokeWidth="1.5" opacity="0.5" />
</svg>
)
}
export function ScriptIllustration() {
return (
<svg width="80" height="60" viewBox="0 0 80 60" fill="none" xmlns="http://www.w3.org/2000/svg">
{/* Terminal window */}
<rect x="8" y="4" width="64" height="52" rx="4" fill="rgba(6,182,212,0.15)" stroke="#06b6d4" strokeWidth="1.5" />
{/* Title bar */}
<line x1="8" y1="14" x2="72" y2="14" stroke="#06b6d4" strokeWidth="1" opacity="0.3" />
<circle cx="16" cy="9" r="2" fill="#06b6d4" opacity="0.4" />
<circle cx="23" cy="9" r="2" fill="#22d3ee" opacity="0.4" />
{/* Code lines */}
<line x1="16" y1="22" x2="40" y2="22" stroke="#06b6d4" strokeWidth="1.5" opacity="0.6" />
<line x1="20" y1="30" x2="52" y2="30" stroke="#22d3ee" strokeWidth="1.5" opacity="0.5" />
<line x1="20" y1="38" x2="46" y2="38" stroke="#06b6d4" strokeWidth="1.5" opacity="0.4" />
<line x1="16" y1="46" x2="36" y2="46" stroke="#22d3ee" strokeWidth="1.5" opacity="0.5" />
</svg>
)
}
export function ShareIllustration() {
return (
<svg width="80" height="60" viewBox="0 0 80 60" fill="none" xmlns="http://www.w3.org/2000/svg">
{/* Center node */}
<circle cx="28" cy="30" r="8" fill="rgba(6,182,212,0.15)" stroke="#06b6d4" strokeWidth="1.5" />
{/* Top-right node */}
<circle cx="58" cy="14" r="6" fill="rgba(6,182,212,0.15)" stroke="#22d3ee" strokeWidth="1.5" />
{/* Bottom-right node */}
<circle cx="58" cy="46" r="6" fill="rgba(6,182,212,0.15)" stroke="#22d3ee" strokeWidth="1.5" />
{/* Connecting lines */}
<line x1="35" y1="25" x2="52" y2="17" stroke="#06b6d4" strokeWidth="1.5" />
<line x1="35" y1="35" x2="52" y2="43" stroke="#06b6d4" strokeWidth="1.5" />
</svg>
)
}

View File

@@ -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(<BrowserRouter>{ui}</BrowserRouter>)
}
describe('EmptyState', () => {
it('renders title and description', () => {
renderWithRouter(
<EmptyState
title="No items found"
description="Try adjusting your filters."
/>
)
expect(screen.getByText('No items found')).toBeInTheDocument()
expect(screen.getByText('Try adjusting your filters.')).toBeInTheDocument()
})
it('renders illustration when provided', () => {
const { container } = renderWithRouter(
<EmptyState
title="Empty"
illustration={<FlowIllustration />}
/>
)
const svg = container.querySelector('svg')
expect(svg).toBeInTheDocument()
})
it('renders action button', () => {
renderWithRouter(
<EmptyState
title="No data"
action={<button>Create New</button>}
/>
)
expect(screen.getByRole('button', { name: 'Create New' })).toBeInTheDocument()
})
it('renders learn more link with correct href', () => {
renderWithRouter(
<EmptyState
title="Get started"
learnMoreLink="/guides/creating-flows"
/>
)
const link = screen.getByText(/Learn more/i)
expect(link).toBeInTheDocument()
expect(link).toHaveAttribute('href', '/guides/creating-flows')
})
it('renders custom learn more text', () => {
renderWithRouter(
<EmptyState
title="Get started"
learnMoreLink="/guides/test"
learnMoreText="View guide"
/>
)
expect(screen.getByText(/View guide/i)).toBeInTheDocument()
})
it('renders without optional props', () => {
renderWithRouter(<EmptyState title="Just a title" />)
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(
<EmptyState
title="Test"
icon={<span data-testid="icon">icon</span>}
illustration={<FlowIllustration />}
/>
)
expect(container.querySelector('svg')).toBeInTheDocument()
expect(screen.queryByTestId('icon')).not.toBeInTheDocument()
})
})

View File

@@ -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<OnboardingStatus | null>(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 (
<div className="glass-card overflow-hidden fade-in" style={{ animationDelay: '150ms' }}>
{/* Progress bar */}
<div className="h-1 w-full bg-[rgba(255,255,255,0.04)]">
<div
className="h-full bg-gradient-brand transition-all duration-500 ease-out"
style={{ width: `${progressPercent}%` }}
/>
</div>
<div className="p-4">
{/* Header */}
<div className="flex items-center justify-between mb-3">
<div>
<p className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">
Getting Started
</p>
<p className="text-sm text-foreground mt-0.5">
{isAllDone ? (
<span className="text-gradient-brand font-semibold">You're all set!</span>
) : (
<span>
<span className="text-gradient-brand font-semibold">{completedCount}</span>
{' '}of {totalCount} complete
</span>
)}
</p>
</div>
<button
onClick={handleDismiss}
className="rounded-md p-1 text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.04)] transition-colors"
aria-label="Dismiss onboarding checklist"
>
<X size={16} />
</button>
</div>
{/* Checklist items */}
<ul className="space-y-1">
{items.map((item) => {
const done = status[item.key]
return (
<li key={item.key}>
<button
onClick={() => !done && navigate(item.path)}
disabled={done}
className={cn(
'w-full flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors text-left',
done
? 'cursor-default'
: 'hover:bg-[rgba(255,255,255,0.04)]'
)}
>
{/* Checkbox */}
<span
className={cn(
'flex h-5 w-5 shrink-0 items-center justify-center rounded-md border transition-colors',
done
? 'bg-gradient-brand border-transparent'
: 'border-border'
)}
>
{done && <Check size={12} className="text-[#101114]" />}
</span>
{/* Label */}
<span
className={cn(
'flex-1',
done
? 'text-muted-foreground line-through'
: 'text-foreground'
)}
>
{item.label}
</span>
{/* Arrow for incomplete items */}
{!done && (
<ChevronRight size={14} className="text-muted-foreground shrink-0" />
)}
</button>
</li>
)
})}
</ul>
</div>
</div>
)
}

View File

@@ -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 (
<div className="flex flex-col items-center justify-center gap-3 py-12 text-center px-4">
<FileCode size={32} className="text-muted-foreground/40" />
<p className="text-sm text-muted-foreground">No templates found</p>
</div>
<EmptyState
illustration={<ScriptIllustration />}
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"
/>
)
}

View File

@@ -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<TabType>('text_snippet')
const [label, setLabel] = useState('')
const [textContent, setTextContent] = useState('')
const [imageBase64, setImageBase64] = useState<string | null>(null)
const [imageContentType, setImageContentType] = useState<string | null>(null)
const [imageFileName, setImageFileName] = useState<string | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const fileInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
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 (
<Modal isOpen={isOpen} onClose={handleClose} title="Add Supporting Data">
{/* Tabs */}
<div className="mb-4 flex gap-1 rounded-lg bg-accent p-1">
<button
type="button"
onClick={() => setActiveTab('text_snippet')}
className={cn(
'flex flex-1 items-center justify-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors',
activeTab === 'text_snippet'
? 'bg-card text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
)}
>
<Code2 className="h-4 w-4" />
Text Snippet
</button>
<button
type="button"
onClick={() => setActiveTab('screenshot')}
className={cn(
'flex flex-1 items-center justify-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors',
activeTab === 'screenshot'
? 'bg-card text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
)}
>
<ImageIcon className="h-4 w-4" />
Screenshot
</button>
</div>
{/* Label */}
<div className="mb-4">
<label htmlFor="sd-label" className="mb-1 block text-sm font-medium text-foreground">
Label
</label>
<input
id="sd-label"
type="text"
value={label}
onChange={(e) => 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'
)}
/>
</div>
{/* Text Snippet Tab Content */}
{activeTab === 'text_snippet' && (
<div className="mb-4">
<label htmlFor="sd-content" className="mb-1 block text-sm font-medium text-foreground">
Content
</label>
<textarea
id="sd-content"
value={textContent}
onChange={(e) => setTextContent(e.target.value)}
placeholder="Paste log output, error messages, config snippets..."
rows={8}
className={cn(
'w-full resize-y rounded-md border border-border bg-card px-3 py-2 text-sm',
'font-mono text-foreground placeholder:text-muted-foreground',
'focus:border-[rgba(6,182,212,0.3)] focus:outline-hidden focus:ring-1 focus:ring-primary/20'
)}
/>
</div>
)}
{/* Screenshot Tab Content */}
{activeTab === 'screenshot' && (
<div className="mb-4" onPaste={handlePaste}>
<label className="mb-1 block text-sm font-medium text-foreground">
Image
</label>
{imageBase64 ? (
<div className="relative rounded-md border border-border bg-card p-2">
<img
src={`data:${imageContentType};base64,${imageBase64}`}
alt="Preview"
className="mx-auto max-h-48 rounded object-contain"
/>
<p className="mt-2 text-center text-xs text-muted-foreground">{imageFileName}</p>
<button
type="button"
onClick={() => {
setImageBase64(null)
setImageContentType(null)
setImageFileName(null)
if (fileInputRef.current) fileInputRef.current.value = ''
}}
className="mt-2 w-full text-center text-xs text-muted-foreground hover:text-foreground"
>
Remove and choose another
</button>
</div>
) : (
<div
onClick={() => fileInputRef.current?.click()}
className={cn(
'flex cursor-pointer flex-col items-center justify-center gap-2 rounded-md border-2 border-dashed border-border',
'bg-card/50 py-10 transition-colors hover:border-muted-foreground'
)}
>
<Upload className="h-8 w-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">Click to upload or paste from clipboard</p>
<p className="text-xs text-muted-foreground">PNG, JPEG, or SVG - max 2MB</p>
</div>
)}
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/svg+xml"
onChange={handleFileChange}
className="hidden"
/>
</div>
)}
{/* Error */}
{error && (
<p className="mb-4 text-sm text-rose-500">{error}</p>
)}
{/* Actions */}
<div className="flex items-center justify-end gap-2">
<Button variant="secondary" onClick={handleClose}>
Cancel
</Button>
<Button onClick={handleSubmit} loading={isSubmitting}>
{isSubmitting ? 'Saving...' : 'Add'}
</Button>
</div>
</Modal>
)
}
export default AddSupportingDataModal

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'
import { Copy, Download, Check, RotateCcw } from 'lucide-react'
import { Copy, Download, Check, RotateCcw, FileDown } from 'lucide-react'
import { Modal } from '@/components/common/Modal'
import { cn } from '@/lib/utils'
import type { RedactionSummary } from '@/types'
@@ -9,8 +9,10 @@ interface ExportPreviewModalProps {
onClose: () => void
content: string
filename: string
format: 'markdown' | 'text' | 'html' | 'psa'
format: 'markdown' | 'text' | 'html' | 'psa' | 'pdf'
onDownload: (content: string) => void
onDownloadPdf?: () => void
pdfLoading?: boolean
includeSummary?: boolean
onToggleSummary?: (include: boolean) => void
redactionEnabled?: boolean
@@ -25,6 +27,8 @@ export function ExportPreviewModal({
filename,
format,
onDownload,
onDownloadPdf,
pdfLoading = false,
includeSummary = false,
onToggleSummary,
redactionEnabled = false,
@@ -72,7 +76,7 @@ export function ExportPreviewModal({
<p className="text-sm text-muted-foreground">
Filename: <span className="font-mono text-foreground">{filename}</span>
<span className="ml-3 rounded bg-accent px-2 py-0.5 text-xs text-muted-foreground">
{format === 'markdown' ? 'Markdown' : format === 'html' ? 'HTML' : format === 'psa' ? 'PSA' : 'Plain Text'}
{format === 'markdown' ? 'Markdown' : format === 'html' ? 'HTML' : format === 'psa' ? 'PSA' : format === 'pdf' ? 'PDF' : 'Plain Text'}
</span>
{isModified && (
<span className="ml-2 text-xs text-yellow-400">(edited)</span>
@@ -130,56 +134,79 @@ export function ExportPreviewModal({
</div>
</div>
{/* Editable Content */}
<label htmlFor="export-content" className="sr-only">
Export content
</label>
<textarea
id="export-content"
value={editedContent}
onChange={(e) => setEditedContent(e.target.value)}
className={cn(
'h-96 w-full resize-y rounded-md border border-border bg-card p-4',
'font-mono text-sm text-foreground',
'focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20'
)}
/>
{format === 'pdf' ? (
/* PDF download-only UI */
<div className="flex flex-col items-center justify-center py-12">
<FileDown className="mb-4 h-12 w-12 text-muted-foreground" />
<p className="text-muted-foreground mb-4">PDF exports are generated server-side with your team&apos;s branding.</p>
<button
type="button"
onClick={onDownloadPdf}
disabled={pdfLoading}
className={cn(
'inline-flex items-center gap-2 rounded-[10px] bg-gradient-brand px-6 py-3 text-sm font-semibold text-[#101114]',
'hover:opacity-90 active:scale-[0.97] transition-all',
'disabled:opacity-60 disabled:cursor-not-allowed'
)}
>
<Download className="h-4 w-4" />
{pdfLoading ? 'Generating PDF...' : 'Download PDF'}
</button>
</div>
) : (
<>
{/* Editable Content */}
<label htmlFor="export-content" className="sr-only">
Export content
</label>
<textarea
id="export-content"
value={editedContent}
onChange={(e) => setEditedContent(e.target.value)}
className={cn(
'h-96 w-full resize-y rounded-md border border-border bg-card p-4',
'font-mono text-sm text-foreground',
'focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20'
)}
/>
{/* Actions */}
<div className="mt-4 flex items-center justify-end gap-2">
<button
type="button"
onClick={handleCopy}
className={cn(
'flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm font-medium',
'text-muted-foreground hover:bg-accent hover:text-foreground',
'focus:outline-hidden focus:ring-2 focus:ring-primary/20'
)}
>
{copied ? (
<>
<Check className="h-4 w-4 text-emerald-400" />
Copied!
</>
) : (
<>
<Copy className="h-4 w-4" />
Copy to Clipboard
</>
)}
</button>
<button
type="button"
onClick={handleDownload}
className={cn(
'flex items-center gap-2 rounded-md bg-gradient-brand text-white shadow-lg shadow-primary/20 px-3 py-2 text-sm font-medium',
'hover:opacity-90 focus:outline-hidden focus:ring-2 focus:ring-primary/20'
)}
>
<Download className="h-4 w-4" />
Download
</button>
</div>
{/* Actions */}
<div className="mt-4 flex items-center justify-end gap-2">
<button
type="button"
onClick={handleCopy}
className={cn(
'flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm font-medium',
'text-muted-foreground hover:bg-accent hover:text-foreground',
'focus:outline-hidden focus:ring-2 focus:ring-primary/20'
)}
>
{copied ? (
<>
<Check className="h-4 w-4 text-emerald-400" />
Copied!
</>
) : (
<>
<Copy className="h-4 w-4" />
Copy to Clipboard
</>
)}
</button>
<button
type="button"
onClick={handleDownload}
className={cn(
'flex items-center gap-2 rounded-md bg-gradient-brand text-white shadow-lg shadow-primary/20 px-3 py-2 text-sm font-medium',
'hover:opacity-90 focus:outline-hidden focus:ring-2 focus:ring-primary/20'
)}
>
<Download className="h-4 w-4" />
Download
</button>
</div>
</>
)}
</Modal>
)
}

View File

@@ -0,0 +1,152 @@
import { useState, useEffect, useCallback } from 'react'
import { Code2, ImageIcon, Plus, Trash2, ChevronDown, ChevronRight } from 'lucide-react'
import { cn } from '@/lib/utils'
import { getSupportingData, deleteSupportingData } from '@/api/supportingData'
import type { SupportingDataItem } from '@/api/supportingData'
import { AddSupportingDataModal } from './AddSupportingDataModal'
import { toast } from '@/lib/toast'
interface SupportingDataPanelProps {
sessionId: string
}
export function SupportingDataPanel({ sessionId }: SupportingDataPanelProps) {
const [items, setItems] = useState<SupportingDataItem[]>([])
const [isExpanded, setIsExpanded] = useState(false)
const [showAddModal, setShowAddModal] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const loadItems = useCallback(async () => {
setIsLoading(true)
try {
const data = await getSupportingData(sessionId)
setItems(data)
} catch (err) {
console.error('Failed to load supporting data:', err)
} finally {
setIsLoading(false)
}
}, [sessionId])
useEffect(() => {
loadItems()
}, [loadItems])
const handleDelete = async (itemId: string) => {
try {
await deleteSupportingData(sessionId, itemId)
setItems((prev) => prev.filter((item) => item.id !== itemId))
toast.success('Supporting data removed')
} catch (err) {
console.error('Failed to delete supporting data:', err)
toast.error('Failed to remove item')
}
}
const handleAdded = () => {
loadItems()
setIsExpanded(true)
}
return (
<div className="rounded-xl border border-border bg-card/50">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3">
<button
type="button"
onClick={() => setIsExpanded(!isExpanded)}
className="flex items-center gap-2 text-sm font-medium text-foreground hover:text-foreground/80"
>
{isExpanded ? (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)}
Supporting Data
{items.length > 0 && (
<span className="rounded-full bg-accent px-2 py-0.5 text-xs text-muted-foreground">
{items.length}
</span>
)}
</button>
<button
type="button"
onClick={() => setShowAddModal(true)}
className={cn(
'flex items-center gap-1.5 rounded-[10px] px-3 py-1.5 text-xs font-medium',
'bg-gradient-brand text-[#101114] hover:opacity-90 active:scale-[0.97] transition-all'
)}
>
<Plus className="h-3.5 w-3.5" />
Add
</button>
</div>
{/* Items list */}
{isExpanded && (
<div className="border-t border-border px-4 py-2">
{isLoading && items.length === 0 ? (
<p className="py-4 text-center text-xs text-muted-foreground">Loading...</p>
) : items.length === 0 ? (
<p className="py-4 text-center text-xs text-muted-foreground">
No supporting data yet. Add text snippets or screenshots to include in exports.
</p>
) : (
<div className="space-y-2 py-1">
{items.map((item) => (
<div
key={item.id}
className={cn(
'flex items-start gap-3 rounded-lg border border-border bg-card/30 px-3 py-2.5',
'group transition-colors hover:border-[rgba(255,255,255,0.12)]'
)}
>
<span className="mt-0.5 shrink-0 text-muted-foreground">
{item.data_type === 'text_snippet' ? (
<Code2 className="h-4 w-4" />
) : (
<ImageIcon className="h-4 w-4" />
)}
</span>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-foreground">{item.label}</p>
{item.data_type === 'text_snippet' && item.content && (
<p className="mt-0.5 truncate text-xs font-mono text-muted-foreground">
{item.content.length > 120 ? item.content.slice(0, 120) + '...' : item.content}
</p>
)}
{item.data_type === 'screenshot' && item.content && (
<img
src={`data:${item.content_type || 'image/png'};base64,${item.content}`}
alt={item.label}
className="mt-1 max-h-16 rounded border border-border object-contain"
/>
)}
</div>
<button
type="button"
onClick={() => handleDelete(item.id)}
className="shrink-0 text-muted-foreground opacity-0 transition-opacity hover:text-rose-500 group-hover:opacity-100"
title="Remove"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
))}
</div>
)}
</div>
)}
{/* Add Modal */}
<AddSupportingDataModal
isOpen={showAddModal}
onClose={() => setShowAddModal(false)}
sessionId={sessionId}
onAdded={handleAdded}
/>
</div>
)
}
export default SupportingDataPanel

View File

@@ -0,0 +1,245 @@
import { useState, useEffect, useRef } from 'react'
import { Palette, Upload, Trash2, Loader2 } from 'lucide-react'
import { getBranding, updateBranding, deleteLogo } from '@/api/branding'
import type { BrandingInfo } from '@/api/branding'
import { Button } from '@/components/ui/Button'
import { cn } from '@/lib/utils'
import { toast } from '@/lib/toast'
interface BrandingSettingsProps {
teamId: string
}
const MAX_LOGO_SIZE = 2 * 1024 * 1024 // 2MB
export function BrandingSettings({ teamId }: BrandingSettingsProps) {
const [branding, setBranding] = useState<BrandingInfo | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [companyName, setCompanyName] = useState('')
const [logoFile, setLogoFile] = useState<File | null>(null)
const [logoPreview, setLogoPreview] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
loadBranding()
}, [teamId])
const loadBranding = async () => {
setIsLoading(true)
try {
const data = await getBranding(teamId)
setBranding(data)
setCompanyName(data.company_display_name || '')
if (data.has_logo) {
// Construct logo URL from the API
const token = localStorage.getItem('access_token')
const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000'
setLogoPreview(`${baseUrl}/api/v1/teams/${teamId}/branding/logo?token=${token}`)
}
} catch (err) {
console.error('Failed to load branding:', err)
} finally {
setIsLoading(false)
}
}
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
if (file.size > MAX_LOGO_SIZE) {
setError('Logo 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)
setLogoFile(file)
const reader = new FileReader()
reader.onload = () => setLogoPreview(reader.result as string)
reader.readAsDataURL(file)
}
const handleSave = async () => {
setIsSaving(true)
setError(null)
try {
const formData = new FormData()
if (companyName.trim()) {
formData.append('company_display_name', companyName.trim())
} else {
formData.append('company_display_name', '')
}
if (logoFile) {
formData.append('logo', logoFile)
}
const updated = await updateBranding(teamId, formData)
setBranding(updated)
setLogoFile(null)
toast.success('Branding settings saved')
} catch (err) {
console.error('Failed to save branding:', err)
setError('Failed to save. Please try again.')
} finally {
setIsSaving(false)
}
}
const handleDeleteLogo = async () => {
setIsDeleting(true)
try {
await deleteLogo(teamId)
setBranding((prev) => prev ? { ...prev, has_logo: false, logo_content_type: null } : prev)
setLogoPreview(null)
setLogoFile(null)
if (fileInputRef.current) fileInputRef.current.value = ''
toast.success('Logo removed')
} catch (err) {
console.error('Failed to delete logo:', err)
toast.error('Failed to remove logo')
} finally {
setIsDeleting(false)
}
}
if (isLoading) {
return (
<div className="glass-card-static p-4 sm:p-6">
<div className="flex items-center gap-2">
<Palette className="h-5 w-5 text-muted-foreground" />
<h2 className="text-lg font-semibold text-foreground">Branding</h2>
</div>
<div className="mt-4 flex justify-center py-4">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
</div>
)
}
const hasChanges = companyName !== (branding?.company_display_name || '') || logoFile !== null
return (
<div className="glass-card-static p-4 sm:p-6">
<div className="flex items-center gap-2">
<Palette className="h-5 w-5 text-muted-foreground" />
<h2 className="text-lg font-semibold text-foreground">Branding</h2>
</div>
<p className="mt-1 text-sm text-muted-foreground">
Customize your company branding for PDF exports
</p>
<div className="mt-4 space-y-5">
{/* Company Display Name */}
<div>
<label htmlFor="company-name" className="block text-sm font-medium text-foreground">
Company Display Name
</label>
<p className="text-xs text-muted-foreground">
Shown in the header of exported PDF reports
</p>
<input
id="company-name"
type="text"
value={companyName}
onChange={(e) => setCompanyName(e.target.value)}
placeholder="Your Company Name"
className={cn(
'mt-2 w-full max-w-md 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'
)}
/>
</div>
{/* Logo Upload */}
<div>
<label className="block text-sm font-medium text-foreground">
Company Logo
</label>
<p className="text-xs text-muted-foreground">
PNG, JPEG, or SVG - max 2MB. Displayed in PDF export headers.
</p>
{logoPreview ? (
<div className="mt-2 flex items-start gap-4">
<div className="rounded-lg border border-border bg-card/50 p-3">
<img
src={logoPreview}
alt="Logo preview"
className="max-h-16 max-w-[200px] object-contain"
/>
</div>
<div className="flex flex-col gap-2">
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="text-sm text-muted-foreground hover:text-foreground"
>
Change logo
</button>
{branding?.has_logo && (
<button
type="button"
onClick={handleDeleteLogo}
disabled={isDeleting}
className="flex items-center gap-1 text-sm text-rose-500 hover:text-rose-400 disabled:opacity-50"
>
{isDeleting ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
Remove logo
</button>
)}
</div>
</div>
) : (
<div
onClick={() => fileInputRef.current?.click()}
className={cn(
'mt-2 flex max-w-md cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed border-border',
'bg-card/30 py-8 transition-colors hover:border-muted-foreground'
)}
>
<Upload className="h-6 w-6 text-muted-foreground" />
<p className="text-sm text-muted-foreground">Click to upload logo</p>
</div>
)}
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/svg+xml"
onChange={handleFileChange}
className="hidden"
/>
</div>
{/* Error */}
{error && (
<p className="text-sm text-rose-500">{error}</p>
)}
{/* Save */}
{hasChanges && (
<div className="pt-2">
<Button onClick={handleSave} loading={isSaving}>
{isSaving ? 'Saving...' : 'Save Branding'}
</Button>
</div>
)}
</div>
</div>
)
}
export default BrandingSettings

View File

@@ -1,6 +1,8 @@
import { useState, useEffect, useMemo } from 'react'
import { Search, ChevronDown, ChevronUp, Loader2 } from 'lucide-react'
import { Button } from '@/components/ui/Button'
import { EmptyState } from '@/components/common/EmptyState'
import { StepLibraryIllustration } from '@/components/common/EmptyStateIllustrations'
import { cn } from '@/lib/utils'
import { stepsApi } from '@/api/steps'
import { stepCategoriesApi } from '@/api/stepCategories'
@@ -259,12 +261,24 @@ export function StepLibraryBrowser({ onInsert, onCreateNew, showCreateButton = f
</Button>
</div>
) : steps.length === 0 ? (
<div className="rounded-lg border border-border bg-accent/50 p-12 text-center">
<p className="mb-2 text-lg font-medium text-foreground">No steps found</p>
<p className="text-sm text-muted-foreground">
{hasActiveFilters ? 'Try adjusting your filters' : 'Create your first step to get started!'}
</p>
</div>
hasActiveFilters ? (
<div className="rounded-lg border border-border bg-accent/50 p-12 text-center">
<p className="mb-2 text-lg font-medium text-foreground">No steps found</p>
<p className="text-sm text-muted-foreground">Try adjusting your filters</p>
</div>
) : (
<EmptyState
illustration={<StepLibraryIllustration />}
title="Build a reusable step library"
description="Save common troubleshooting steps once, reuse them across flows. Keeps your team consistent and saves build time."
action={
onCreateNew ? (
<Button onClick={onCreateNew}>Browse Steps</Button>
) : undefined
}
learnMoreLink="/guides/step-library"
/>
)
) : (
<div className="space-y-6">
{/* My Steps */}

View File

@@ -13,6 +13,8 @@ import {
Wrench,
Settings,
BarChart3,
Terminal,
Plug,
} from 'lucide-react'
export interface GuideStep {
@@ -492,4 +494,82 @@ export const guides: Guide[] = [
},
],
},
{
slug: 'script-templates',
title: 'Script Templates',
icon: Terminal,
summary: 'Browse, configure, and generate scripts from reusable templates.',
sections: [
{
title: 'Browsing Templates',
steps: [
{ instruction: 'Click **Scripts** in the sidebar to open the Script Library.' },
{ instruction: 'The left pane lists all available templates organized by category.' },
{ instruction: 'Use the search bar to filter templates by name or keyword.' },
{ instruction: 'Click any template to preview its script content in the right pane.' },
],
},
{
title: 'Configuring and Generating Scripts',
steps: [
{ instruction: 'Click **Configure** on a template to enter parameter values.' },
{ instruction: 'Fill in the required fields (e.g., server name, IP address, credentials).' },
{ instruction: 'Click **Generate** to produce a ready-to-run script with your values substituted.' },
{ instruction: 'Copy the generated script to your clipboard or download it directly.', tip: 'Double-check generated scripts in a test environment before running them in production.' },
],
},
{
title: 'Managing Templates',
steps: [
{ instruction: 'Click **Manage Templates** at the top of the Script Library page.' },
{ instruction: 'Create new templates with a name, category, script body, and configurable parameters.' },
{ instruction: 'Edit or delete existing templates from the management page.' },
{ instruction: 'Templates support PowerShell, Bash, Python, and other scripting languages.' },
],
},
],
},
{
slug: 'psa-setup',
title: 'PSA Integration Setup',
icon: Plug,
summary: 'Connect ConnectWise or other PSA tools to ResolutionFlow.',
sections: [
{
title: 'Getting Your API Credentials',
steps: [
{ instruction: 'Log in to your ConnectWise PSA instance as an admin.' },
{ instruction: 'Navigate to **System > Members > API Members** and create a new API member.' },
{ instruction: 'Generate an **API key pair** (public key and private key) for the member.' },
{ instruction: 'Note your **Company ID** (the company identifier used to log in) and **Site URL** (e.g., na.myconnectwise.net).', tip: 'Create a dedicated API member for ResolutionFlow with minimal permissions for security.' },
],
},
{
title: 'Connecting in ResolutionFlow',
steps: [
{ instruction: 'Go to **Account > Integrations** in ResolutionFlow.' },
{ instruction: 'Enter a display name, your Site URL, Company ID, Public Key, and Private Key.' },
{ instruction: 'Click **Connect** to save the connection.' },
{ instruction: 'Click **Test Connection** to verify everything is working correctly.' },
],
},
{
title: 'Member Mapping',
steps: [
{ instruction: 'After connecting, switch to the **Member Mapping** tab.' },
{ instruction: 'Click **Auto-Match by Email** to automatically pair ResolutionFlow users with ConnectWise members by email address.' },
{ instruction: 'Manually adjust any unmatched or incorrectly matched members using the dropdowns.' },
{ instruction: 'Click **Save Mappings** to apply changes. Mapped members are used when posting session notes to tickets.' },
],
},
{
title: 'What the Integration Enables',
steps: [
{ instruction: 'Session documentation can be posted directly to ConnectWise tickets as internal notes.' },
{ instruction: 'Ticket context (client info, issue details) can be pulled into sessions for AI-assisted troubleshooting.' },
{ instruction: 'Posts are attributed to the correct ConnectWise member based on your member mappings.' },
],
},
],
},
]

View File

@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { Building2, Users, Mail, Crown, Loader2, AlertCircle, Check, X, Settings, FolderTree, Server, RefreshCw, MessageSquareText, UserCog, AlertTriangle, Clock, Plug } from 'lucide-react'
import { PageMeta } from '@/components/common/PageMeta'
import { BrandingSettings } from '@/components/settings/BrandingSettings'
import { accountsApi } from '@/api/accounts'
import type { Account, AccountMember, AccountInvite } from '@/types'
import { TransferOwnershipModal } from '@/components/account/TransferOwnershipModal'
@@ -21,6 +22,7 @@ export function AccountSettingsPage() {
const { isAccountOwner } = usePermissions()
const { plan, limits, usage } = useSubscription()
const { defaultExportFormat, setDefaultExportFormat } = useUserPreferencesStore()
const user = useAuthStore((s) => s.user)
const subscription = useAuthStore((s) => s.subscription)
const [account, setAccount] = useState<Account | null>(null)
@@ -587,6 +589,11 @@ export function AccountSettingsPage() {
<span className="text-muted-foreground group-hover:text-foreground transition-colors">&rarr;</span>
</Link>
{/* Branding Section (owners only) */}
{isAccountOwner && user?.team_id && (
<BrandingSettings teamId={user.team_id} />
)}
{/* Preferences Section */}
<div className="bg-card border border-border rounded-xl p-4 sm:p-6">
<div className="flex items-center gap-2">

View File

@@ -12,6 +12,7 @@ import {
} from 'recharts'
import { Spinner } from '@/components/common/Spinner'
import { EmptyState } from '@/components/common/EmptyState'
import { AnalyticsIllustration } from '@/components/common/EmptyStateIllustrations'
import { analyticsApi } from '@/api'
import { usePermissions } from '@/hooks/usePermissions'
import type { PersonalAnalyticsResponse, AnalyticsPeriod } from '@/types'
@@ -57,6 +58,7 @@ export default function MyAnalyticsPage() {
return (
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
<EmptyState
illustration={<AnalyticsIllustration />}
title="Analytics unavailable"
description="Failed to load analytics data. Please try again."
/>
@@ -64,6 +66,27 @@ export default function MyAnalyticsPage() {
)
}
if (data.summary.total_sessions === 0) {
return (
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
<EmptyState
illustration={<AnalyticsIllustration />}
title="Track your troubleshooting performance"
description="Analytics show resolution times, common paths, and team efficiency. Data appears automatically as you complete sessions."
action={
<Link
to="/trees"
className="inline-flex items-center gap-2 rounded-[10px] bg-gradient-brand px-5 py-2.5 text-sm font-semibold text-[#101114] shadow-lg shadow-primary/20 hover:opacity-90 active:scale-[0.97] transition-all"
>
Run Your First Session
</Link>
}
learnMoreLink="/guides/analytics"
/>
</div>
)
}
const { summary, time_series, top_flows } = data
const outcomeBreakdown = summary.outcome_breakdown

View File

@@ -1,10 +1,11 @@
import { useState, useEffect, useCallback } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { Globe, Users, Copy, Check, Link2, ExternalLink, Trash2, ArrowLeft } from 'lucide-react'
import { Globe, Users, Copy, Check, ExternalLink, Trash2, ArrowLeft } from 'lucide-react'
import { PageMeta } from '@/components/common/PageMeta'
import { Button } from '@/components/ui/Button'
import { Spinner } from '@/components/common/Spinner'
import { EmptyState } from '@/components/common/EmptyState'
import { ShareIllustration } from '@/components/common/EmptyStateIllustrations'
import { ConfirmDialog } from '@/components/common/ConfirmDialog'
import { cn } from '@/lib/utils'
import { toast } from '@/lib/toast'
@@ -142,18 +143,17 @@ export default function MySharesPage() {
{/* Empty state */}
{shares.length === 0 ? (
<div className="bg-card border border-border rounded-xl">
<EmptyState
icon={<Link2 className="h-12 w-12" />}
title="No shared sessions"
description="Share a session from the session detail page to create a link"
action={
<Button onClick={() => navigate('/sessions')}>
Go to Sessions
</Button>
}
/>
</div>
<EmptyState
illustration={<ShareIllustration />}
title="Share session results with your team"
description="Create shareable links to completed sessions for knowledge sharing and client communication."
action={
<Button onClick={() => navigate('/sessions')}>
View Sessions
</Button>
}
learnMoreLink="/guides/sharing-exports"
/>
) : (
<div className="space-y-4">
{shares.map((share) => {

View File

@@ -25,6 +25,7 @@ import type { CustomStepDraft } from '@/components/step-library/CustomStepModal'
import { PostStepActionModal } from '@/components/session/PostStepActionModal'
import { CopilotPanel } from '@/components/copilot/CopilotPanel'
import { CopilotToggle } from '@/components/copilot/CopilotToggle'
import { SupportingDataPanel } from '@/components/session/SupportingDataPanel'
import { integrationsApi, sessionPsaApi } from '@/api/integrations'
import { TicketPickerModal } from '@/components/session/TicketPickerModal'
import { TicketLinkIndicator } from '@/components/session/TicketLinkIndicator'
@@ -792,6 +793,13 @@ export function ProceduralNavigationPage() {
<StepFeedback stepId={currentStep.id} sessionId={session.id} />
</div>
)}
{/* Supporting Data */}
{session && (
<div className="mt-4">
<SupportingDataPanel sessionId={session.id} />
</div>
)}
</div>
{/* AI Copilot - in-flow panel */}

View File

@@ -26,6 +26,7 @@ import { QuickActions } from '@/components/dashboard/QuickActions'
import { OpenSessions } from '@/components/dashboard/OpenSessions'
import { RecentActivity } from '@/components/dashboard/RecentActivity'
import { PreparedSessions } from '@/components/dashboard/PreparedSessions'
import { OnboardingChecklist } from '@/components/dashboard/OnboardingChecklist'
function timeAgo(dateStr: string): string {
const now = Date.now()
@@ -278,6 +279,9 @@ export function QuickStartPage() {
</p>
</div>
{/* Onboarding Checklist */}
<OnboardingChecklist />
{/* Row 1: Calendar + Quick Actions */}
<div className="flex gap-4" style={{ alignItems: 'stretch' }}>
<div className="flex-1 min-w-0">

View File

@@ -32,7 +32,7 @@ export function SessionDetailPage() {
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [isExporting, setIsExporting] = useState(false)
const [exportFormat, setExportFormat] = useState<'markdown' | 'text' | 'html' | 'psa'>(defaultExportFormat)
const [exportFormat, setExportFormat] = useState<'markdown' | 'text' | 'html' | 'psa' | 'pdf'>(defaultExportFormat)
const [exportContent, setExportContent] = useState<string | null>(null)
const [showPreview, setShowPreview] = useState(false)
const [copied, setCopied] = useState(false)
@@ -51,6 +51,7 @@ export function SessionDetailPage() {
const [redactionMode, setRedactionMode] = useState<'none' | 'mask'>('none')
const [redactionSummary, setRedactionSummary] = useState<RedactionSummary | null>(null)
const [isGeneratingFlow, setIsGeneratingFlow] = useState(false)
const [pdfLoading, setPdfLoading] = useState(false)
useEffect(() => {
if (id) {
@@ -223,6 +224,31 @@ export function SessionDetailPage() {
}
}
const handleDownloadPdf = async () => {
if (!session) return
setPdfLoading(true)
try {
const { apiClient } = await import('@/api/client')
const response = await apiClient.post(
`/sessions/${session.id}/export`,
{ format: 'pdf' },
{ responseType: 'blob' }
)
const url = URL.createObjectURL(response.data)
const a = document.createElement('a')
a.href = url
a.download = `session-export-${session.id}.pdf`
a.click()
URL.revokeObjectURL(url)
analytics.exportGenerated({ session_id: session.id, format: 'pdf' })
} catch (error) {
console.error('PDF export failed:', error)
toast.error('Failed to generate PDF export')
} finally {
setPdfLoading(false)
}
}
const handleSaveAsTree = async (data: SaveAsTreeRequest) => {
if (!session) return
setIsSavingTree(true)
@@ -478,6 +504,7 @@ export function SessionDetailPage() {
<option value="text">Plain Text</option>
<option value="html">HTML</option>
<option value="psa">PSA / Ticket Note</option>
<option value="pdf">PDF</option>
</select>
{session.decisions.length > 1 && (
<select
@@ -546,6 +573,8 @@ export function SessionDetailPage() {
filename={getFilename()}
format={exportFormat}
onDownload={handleDownload}
onDownloadPdf={handleDownloadPdf}
pdfLoading={pdfLoading}
includeSummary={includeSummary}
onToggleSummary={handleToggleSummary}
redactionEnabled={redactionMode === 'mask'}

View File

@@ -1,5 +1,5 @@
import { useEffect, useState, useRef, useCallback } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
import { PageMeta } from '@/components/common/PageMeta'
import { sessionsApi } from '@/api/sessions'
import { treesApi } from '@/api/trees'
@@ -9,6 +9,7 @@ import { SessionFilters } from '@/components/session/SessionFilters'
import type { SessionFilterState } from '@/components/session/SessionFilters'
import { Spinner } from '@/components/common/Spinner'
import { EmptyState } from '@/components/common/EmptyState'
import { SessionIllustration } from '@/components/common/EmptyStateIllustrations'
import { cn } from '@/lib/utils'
import { toast } from '@/lib/toast'
import { getSessionResumePath } from '@/lib/routing'
@@ -259,19 +260,32 @@ export function SessionHistoryPage() {
<Spinner />
</div>
) : sessions.length === 0 ? (
<EmptyState
title="No sessions found"
description={filters.ticketNumber || filters.clientName || filters.treeName || filters.dateRange?.from
? "Try adjusting your filters"
: "Complete a flow to see it here"}
action={
(filters.ticketNumber || filters.clientName || filters.treeName || filters.dateRange?.from) ? (
(filters.ticketNumber || filters.clientName || filters.treeName || filters.dateRange?.from) ? (
<EmptyState
title="No sessions match your filters"
description="Try adjusting your search or filters."
action={
<button onClick={handleClearFilters} className="text-foreground hover:underline text-sm">
Clear all filters
</button>
) : undefined
}
/>
}
/>
) : (
<EmptyState
illustration={<SessionIllustration />}
title="Your session history will appear here"
description="Every troubleshooting session is recorded with decisions, timing, and outcomes — ready for export or review."
action={
<Link
to="/trees"
className="inline-flex items-center gap-2 rounded-[10px] bg-gradient-brand px-5 py-2.5 text-sm font-semibold text-[#101114] shadow-lg shadow-primary/20 hover:opacity-90 active:scale-[0.97] transition-all"
>
Start a Session
</Link>
}
learnMoreLink="/guides/sessions"
/>
)
) : (
<>
<div className="space-y-4">

View File

@@ -12,6 +12,7 @@ import {
} from 'recharts'
import { Spinner } from '@/components/common/Spinner'
import { EmptyState } from '@/components/common/EmptyState'
import { AnalyticsIllustration } from '@/components/common/EmptyStateIllustrations'
import { analyticsApi } from '@/api'
import { usePermissions } from '@/hooks/usePermissions'
import { toast } from '@/lib/toast'
@@ -70,6 +71,7 @@ export default function TeamAnalyticsPage() {
return (
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
<EmptyState
illustration={<AnalyticsIllustration />}
title="Analytics unavailable"
description="Failed to load analytics data. Please try again."
/>
@@ -77,6 +79,27 @@ export default function TeamAnalyticsPage() {
)
}
if (data.summary.total_sessions === 0) {
return (
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
<EmptyState
illustration={<AnalyticsIllustration />}
title="Track your troubleshooting performance"
description="Analytics show resolution times, common paths, and team efficiency. Data appears automatically as you complete sessions."
action={
<Link
to="/trees"
className="inline-flex items-center gap-2 rounded-[10px] bg-gradient-brand px-5 py-2.5 text-sm font-semibold text-[#101114] shadow-lg shadow-primary/20 hover:opacity-90 active:scale-[0.97] transition-all"
>
Run Your First Session
</Link>
}
learnMoreLink="/guides/analytics"
/>
</div>
)
}
const { summary, time_series, top_flows, top_engineers } = data
return (

View File

@@ -3,6 +3,7 @@ import { useNavigate, useSearchParams } from 'react-router-dom'
import { X, RotateCcw, Play, FileUp } from 'lucide-react'
import { PageMeta } from '@/components/common/PageMeta'
import { Button } from '@/components/ui/Button'
import { FlowIllustration } from '@/components/common/EmptyStateIllustrations'
import { treesApi } from '@/api/trees'
import { categoriesApi } from '@/api/categories'
import { foldersApi } from '@/api/folders'
@@ -496,14 +497,29 @@ export function TreeLibraryPage() {
<Spinner />
</div>
) : trees.length === 0 ? (
<EmptyState
title="No flows found"
description={
(searchQuery || hasActiveFilters)
? 'Try adjusting your filters.'
: 'Create your first flow to get started.'
}
/>
(searchQuery || hasActiveFilters) ? (
<EmptyState
title="No flows match your filters"
description="Try adjusting your search or filters."
action={
<Button variant="secondary" onClick={clearAllFilters}>
Clear Filters
</Button>
}
/>
) : (
<EmptyState
illustration={<FlowIllustration />}
title="Build your first troubleshooting flow"
description="Flows guide your team through proven resolution paths, capturing every decision along the way."
action={
canCreateTrees ? (
<CreateFlowDropdown aiEnabled={aiEnabled} label="Create a Flow" />
) : undefined
}
learnMoreLink="/guides/creating-flows"
/>
)
) : (
<>
{treeLibraryView === 'grid' && (

View File

@@ -21,6 +21,7 @@ import { StepFeedback } from '@/components/session/StepFeedback'
import { buildSessionShareUrl, getLatestActiveShareForSession } from '@/lib/sessionShare'
import { CopilotPanel } from '@/components/copilot/CopilotPanel'
import { CopilotToggle } from '@/components/copilot/CopilotToggle'
import { SupportingDataPanel } from '@/components/session/SupportingDataPanel'
import { Button } from '@/components/ui/Button'
import { integrationsApi, sessionPsaApi } from '@/api/integrations'
import { TicketPickerModal } from '@/components/session/TicketPickerModal'
@@ -1204,6 +1205,13 @@ export function TreeNavigationPage() {
/>
</div>
{/* Supporting Data */}
{session && (
<div className="mt-4">
<SupportingDataPanel sessionId={session.id} />
</div>
)}
{/* Back Button */}
{pathTaken.length > 1 && (
<button

View File

@@ -1,6 +1,8 @@
import { useEffect, useState } from 'react'
import { Plug, CheckCircle2, AlertCircle, Loader2, Pencil, Trash2, Shield, History, Ticket, Users, Zap, Save } from 'lucide-react'
import { analytics } from '@/lib/analytics'
import { EmptyState } from '@/components/common/EmptyState'
import { IntegrationIllustration } from '@/components/common/EmptyStateIllustrations'
import { PageMeta } from '@/components/common/PageMeta'
import { integrationsApi } from '@/api/integrations'
import type { PsaConnectionResponse, PsaConnectionCreate, PsaConnectionUpdate, PsaConnectionTestResponse } from '@/types'
@@ -254,6 +256,18 @@ export function IntegrationsPage() {
{/* Connection Tab */}
{activeTab === 'connection' && (
<div className="max-w-3xl">
{/* Illustrative empty state when no connection exists */}
{mode === 'setup' && (
<div className="mb-6">
<EmptyState
illustration={<IntegrationIllustration />}
title="Connect your PSA for seamless workflows"
description="Link ConnectWise or other PSA tools to pull ticket context into sessions and push documentation back automatically."
learnMoreLink="/guides/psa-setup"
/>
</div>
)}
{/* Setup / Edit Form */}
{(mode === 'setup' || mode === 'edit') && (
<div className="glass-card-static p-6">

View File

@@ -103,7 +103,7 @@ export interface SessionUpdate {
}
export interface SessionExport {
format: 'text' | 'markdown' | 'html' | 'psa'
format: 'text' | 'markdown' | 'html' | 'psa' | 'pdf'
include_timestamps?: boolean
include_tree_info?: boolean
include_outcome_notes?: boolean

View File

@@ -10,6 +10,7 @@ export interface User {
must_change_password: boolean
account_id: string | null
account_role: 'owner' | 'engineer' | 'viewer' | null
team_id: string | null
created_at: string
last_login: string | null
phone: string | null