From 719db2279f252a9257e715a6d59c2745b3e9b060 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Mon, 16 Mar 2026 22:52:34 -0400 Subject: [PATCH 01/16] docs: add design spec for empty states, onboarding, and professional exports Co-Authored-By: Claude Opus 4.6 (1M context) --- ...-empty-states-onboarding-exports-design.md | 424 ++++++++++++++++++ 1 file changed, 424 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-16-empty-states-onboarding-exports-design.md diff --git a/docs/superpowers/specs/2026-03-16-empty-states-onboarding-exports-design.md b/docs/superpowers/specs/2026-03-16-empty-states-onboarding-exports-design.md new file mode 100644 index 00000000..9b71ffd8 --- /dev/null +++ b/docs/superpowers/specs/2026-03-16-empty-states-onboarding-exports-design.md @@ -0,0 +1,424 @@ +# 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.3` → `0.5` range for subtlety +- Title: `text-foreground`, 14px, `font-semibold` +- 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 `user_preferences` (existing JSON column) +- 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 `dismissed=true` in user preferences. + +### 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: same fields stored on user record or preferences — decided during implementation + +### Why Base64 in DB + +Logos are small (< 2MB) and there's one per team. 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` + +**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 +- Returns `application/pdf` with `Content-Disposition: attachment; filename="session-export-{id}.pdf"` header + +### 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) +- Show a loading spinner while PDF generates server-side +- Existing formats (markdown, text, HTML, PSA) continue to work as before + +### 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 | + +### 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`. +- `DELETE /api/v1/sessions/{session_id}/supporting-data/{id}` — remove an item. + +### Validation + +- Image size: max 5MB per screenshot +- Text snippet: max 50,000 characters +- Max 20 items per session +- Only the session owner or team admins can add/delete + +### Session Runner UI + +- **"Add Supporting Data" button** — positioned near the existing notes input in both troubleshooting and procedural session runners +- **Add modal** with two tabs/options: + - **Text Snippet** — label input + multiline textarea + - **Screenshot** — label input + drag-and-drop zone / file picker + clipboard paste (`Ctrl+V`) support +- **Supporting data list** — collapsible section below session notes showing added items: + - Each item: type icon (code bracket for text, image icon for screenshot) + label + preview (truncated text or thumbnail) + delete button + - No reordering in v1 — items display in creation order + +### Export Integration + +Supporting data is included in all export formats: + +| Format | Text Snippets | Screenshots | +|--------|--------------|-------------| +| Markdown | Labeled fenced code blocks | `![label](data:image/...)` or `[Screenshot: label]` | +| Plain Text | Labeled indented blocks | `[Screenshot: {label}]` placeholder | +| HTML | `
` blocks with labels | `` tags with base64 src |
+| PSA | Labeled code blocks (markdown) | `[Screenshot: {label}]` placeholder |
+| PDF | Styled code blocks matching mockup | Embedded images |
+
+---
+
+## 6. User Guides
+
+### Route
+
+`/guides/:slug` — new frontend route inside the authenticated app shell.
+
+### Implementation
+
+- Markdown files stored in `frontend/src/content/guides/`
+- Rendered with `react-markdown` (or similar lightweight renderer)
+- Displayed in a `.glass-card-static` container within the standard app shell layout
+- Simple breadcrumb: "Guides → {title}"
+
+### 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 5MB 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
+- Team branding columns + migration
+- Branding CRUD endpoints
+- Supporting data table + migration
+- Supporting data CRUD endpoints
+- Onboarding status endpoint
+- WeasyPrint dependency + PDF generation in export service
+- 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.
-- 
2.49.1


From 4add88f5ec5d78becbc4cef26fc54d2d8fc82eb3 Mon Sep 17 00:00:00 2001
From: chihlasm 
Date: Mon, 16 Mar 2026 22:57:15 -0400
Subject: [PATCH 02/16] =?UTF-8?q?docs:=20address=20spec=20review=20?=
 =?UTF-8?q?=E2=80=94=20fix=20critical=20and=20important=20issues?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Fixes: missing user_preferences column (use onboarding_dismissed column),
PDF endpoint return type branching, WeasyPrint system deps, solo pro
branding storage, screenshot size limit, supporting data PATCH endpoint,
ExportPreviewModal download-only mode, guides as React components,
illustration opacity and title size corrections, unknown guide slug handling.

Co-Authored-By: Claude Opus 4.6 (1M context) 
---
 ...-empty-states-onboarding-exports-design.md | 46 +++++++++++++------
 1 file changed, 31 insertions(+), 15 deletions(-)

diff --git a/docs/superpowers/specs/2026-03-16-empty-states-onboarding-exports-design.md b/docs/superpowers/specs/2026-03-16-empty-states-onboarding-exports-design.md
index 9b71ffd8..b3087993 100644
--- a/docs/superpowers/specs/2026-03-16-empty-states-onboarding-exports-design.md
+++ b/docs/superpowers/specs/2026-03-16-empty-states-onboarding-exports-design.md
@@ -64,8 +64,8 @@ Simple SVG line art using the cyan brand color palette (`#06b6d4` → `#22d3ee`)
 ### Visual Style
 
 - Container: centered content within the page's existing layout
-- Illustration: 60-80px height, `opacity: 0.3` → `0.5` range for subtlety
-- Title: `text-foreground`, 14px, `font-semibold`
+- 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
@@ -83,7 +83,7 @@ Dismissible `.glass-card` widget on QuickStartPage, positioned below the greetin
 - 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 `user_preferences` (existing JSON column)
+- 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
@@ -118,7 +118,7 @@ No new database table. A single API endpoint queries existing data to determine
 | `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 `dismissed=true` in user preferences.
+**Dismiss endpoint:** `POST /api/v1/users/onboarding-status/dismiss` — sets `onboarding_dismissed=True` on the user record.
 
 ### Checklist Variants
 
@@ -178,11 +178,11 @@ New "Branding" section on the existing Team Settings page. Team admin only. Solo
 
 - File size: max 2MB
 - Content type: `image/png`, `image/jpeg`, `image/svg+xml`
-- Solo pros: same fields stored on user record or preferences — decided during implementation
+- 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) and there's one per team. Avoids S3/file storage dependency entirely. Easy to migrate to object storage later when BYOS is implemented for supporting data.
+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.
 
 ---
 
@@ -192,6 +192,12 @@ Logos are small (< 2MB) and there's one per team. Avoids S3/file storage depende
 
 **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`
@@ -201,7 +207,9 @@ Logos are small (< 2MB) and there's one per team. Avoids S3/file storage depende
 **Existing endpoint change:**
 
 - `POST /sessions/{session_id}/export` gains `format: "pdf"` option
-- Returns `application/pdf` with `Content-Disposition: attachment; filename="session-export-{id}.pdf"` header
+- 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
 
@@ -226,9 +234,9 @@ Matches the approved mockup layout:
 ### 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)
+- 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
+- Existing formats (markdown, text, HTML, PSA) continue to work as before with the textarea preview
 
 ### Branding Logic
 
@@ -254,19 +262,22 @@ Matches the approved mockup layout:
 | `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 5MB per screenshot
+- 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
 
@@ -301,9 +312,11 @@ Supporting data is included in all export formats:
 ### Implementation
 
 - Markdown files stored in `frontend/src/content/guides/`
-- Rendered with `react-markdown` (or similar lightweight renderer)
+- 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)
 
@@ -385,12 +398,15 @@ These Playwright tests focus on happy paths only — one representative flow per
 ## Implementation Order (Bottom-Up)
 
 ### PR 1: Backend Foundation
-- Team branding columns + migration
+- `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
-- Onboarding status endpoint
-- WeasyPrint dependency + PDF generation in export service
+- 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
-- 
2.49.1


From 9897b660dd74e1b2546915a985bc60e5830610d0 Mon Sep 17 00:00:00 2001
From: chihlasm 
Date: Mon, 16 Mar 2026 22:59:04 -0400
Subject: [PATCH 03/16] docs: fix test spec screenshot size to match 2MB
 validation limit

Co-Authored-By: Claude Opus 4.6 (1M context) 
---
 .../specs/2026-03-16-empty-states-onboarding-exports-design.md  | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/docs/superpowers/specs/2026-03-16-empty-states-onboarding-exports-design.md b/docs/superpowers/specs/2026-03-16-empty-states-onboarding-exports-design.md
index b3087993..3a9b8c92 100644
--- a/docs/superpowers/specs/2026-03-16-empty-states-onboarding-exports-design.md
+++ b/docs/superpowers/specs/2026-03-16-empty-states-onboarding-exports-design.md
@@ -364,7 +364,7 @@ Supporting data is included in all export formats:
 - List items — returns in sort order
 - Delete item — removes from DB
 - Exceed 20 item limit — returns 400
-- Exceed 5MB screenshot — returns 400
+- Exceed 2MB screenshot — returns 400
 - Non-owner cannot add to session — returns 403
 
 **PDF export:**
-- 
2.49.1


From ae6b7b30551833ee31dfc5726396665a9ba5666e Mon Sep 17 00:00:00 2001
From: chihlasm 
Date: Mon, 16 Mar 2026 23:23:22 -0400
Subject: [PATCH 04/16] docs: add implementation plan for empty states,
 onboarding, and exports

24 tasks across 4 PRs. Addresses all spec review findings:
model code uses Mapped[] syntax, standalone export functions,
team-scoped access checks, variable resolution in PDF, Jinja2/CSS fix,
Vitest + Playwright test tasks included.

Co-Authored-By: Claude Opus 4.6 (1M context) 
---
 ...6-03-16-empty-states-onboarding-exports.md | 2650 +++++++++++++++++
 1 file changed, 2650 insertions(+)
 create mode 100644 docs/superpowers/plans/2026-03-16-empty-states-onboarding-exports.md

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

{{ summary }}

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

{title}

+ {description && ( +

{description}

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

Creating Flows

+

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

+ +

Flow Types

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

Creating a Flow Manually

+

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

+ +

Using AI to Generate Flows

+

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

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

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

+ +
+) : ( + // existing textarea + copy/download buttons +)} +``` + +- [ ] **Step 2: Update SessionDetailPage** + +In `frontend/src/pages/SessionDetailPage.tsx`, add 'pdf' to the format options and implement the PDF download handler: + +```typescript +const handleDownloadPdf = async () => { + setLoading(true) + try { + const response = await apiClient.post( + `/sessions/${sessionId}/export`, + { format: 'pdf' }, + { responseType: 'blob' } + ) + const url = URL.createObjectURL(response.data) + const a = document.createElement('a') + a.href = url + a.download = `session-export-${sessionId}.pdf` + a.click() + URL.revokeObjectURL(url) + } catch (error) { + console.error('PDF export failed:', error) + } finally { + setLoading(false) + } +} +``` + +- [ ] **Step 3: Verify frontend build** + +Run: `cd frontend && npm run build` +Expected: Build succeeds. + +- [ ] **Step 4: Commit** + +```bash +git add frontend/src/components/session/ExportPreviewModal.tsx frontend/src/pages/SessionDetailPage.tsx +git commit -m "feat: add PDF export option with download-only mode in export modal" +``` + +--- + +### Task 22: Branding Settings UI + +**Files:** +- Create: `frontend/src/components/settings/BrandingSettings.tsx` +- Modify: relevant team settings page + +- [ ] **Step 1: Create BrandingSettings component** + +Create `frontend/src/components/settings/BrandingSettings.tsx` with: +- Company display name text input +- Logo upload area (drag-and-drop + file picker) +- Logo preview showing current logo +- Delete logo button +- Save button that calls `updateBranding` with FormData +- 2MB file size validation +- Accepts PNG, JPG, SVG only +- Preview of how logo appears on export (small mockup showing header layout) + +- [ ] **Step 2: Add to team settings page** + +Read the team settings page and add a "Branding" section with the `BrandingSettings` component. Only visible to team admins. + +- [ ] **Step 3: Verify frontend build** + +Run: `cd frontend && npm run build` +Expected: Build succeeds. + +- [ ] **Step 4: Commit** + +```bash +git add frontend/src/components/settings/BrandingSettings.tsx frontend/src/pages/ +git commit -m "feat: add branding settings UI for team logo and company name" +``` + +--- + +### Task 23: Playwright E2E Tests + +**Files:** +- Create: `frontend/e2e/empty-states.spec.ts` +- Create: `frontend/e2e/guides.spec.ts` + +- [ ] **Step 1: Write empty state Playwright test** + +Create `frontend/e2e/empty-states.spec.ts`: + +```typescript +import { test, expect } from '@playwright/test' + +test.describe('Empty States', () => { + test('Flow Library shows empty state with CTA and Learn more', async ({ page }) => { + // Login as a fresh user (or user with no flows) + // Navigate to /trees + // Verify empty state illustration is visible + // Verify "Build your first troubleshooting flow" title + // Verify "Create a Flow" CTA button + // Verify "Learn more →" link pointing to /guides/creating-flows + await page.goto('/trees') + await expect(page.getByText('Build your first troubleshooting flow')).toBeVisible() + await expect(page.getByText('Learn more →')).toBeVisible() + }) +}) +``` + +- [ ] **Step 2: Write guide page Playwright test** + +Create `frontend/e2e/guides.spec.ts`: + +```typescript +import { test, expect } from '@playwright/test' + +test.describe('Guide Pages', () => { + test('Guide page loads from Learn more link', async ({ page }) => { + await page.goto('/guides/creating-flows') + await expect(page.getByText('Creating Flows')).toBeVisible() + }) + + test('Unknown guide slug shows not-found state', async ({ page }) => { + await page.goto('/guides/nonexistent') + await expect(page.getByText('Guide not found')).toBeVisible() + }) +}) +``` + +**Note:** These tests need authentication setup. Use the existing Playwright auth fixtures from `frontend/e2e/fixtures/auth.ts`. Adapt the test user and login pattern to the project's existing Playwright setup. Read the existing e2e tests first. + +- [ ] **Step 3: Run Playwright tests** + +Run: `cd frontend && npx playwright test e2e/empty-states.spec.ts e2e/guides.spec.ts` +Expected: PASS + +- [ ] **Step 4: Commit** + +```bash +git add frontend/e2e/empty-states.spec.ts frontend/e2e/guides.spec.ts +git commit -m "test: add Playwright e2e tests for empty states and guides" +``` + +--- + +### Task 24: PR 4 — Final Build & PR + +- [ ] **Step 1: Full frontend build** + +```bash +cd frontend && npm run build +``` + +- [ ] **Step 2: Full backend test suite** + +```bash +cd backend && pytest --override-ini="addopts=" -v +``` + +- [ ] **Step 3: Push branch and create PR** + +```bash +git push -u origin feat/pdf-export-supporting-data-branding-ui +``` + +Create PR: "feat: PDF export, supporting data capture, and branding settings UI" + +--- + +## Post-Implementation + +- [ ] Update `CURRENT-STATE.md` — move empty states, onboarding, and exports to completed +- [ ] Update `03-DEVELOPMENT-ROADMAP.md` — check off these items +- [ ] Close related GitHub issues +- [ ] Update `CLAUDE.md` if new patterns emerged (e.g., guide page pattern, WeasyPrint usage) -- 2.49.1 From f16a686fb4fe154396507f554d6240a9c6d3ce3b Mon Sep 17 00:00:00 2001 From: chihlasm Date: Mon, 16 Mar 2026 23:51:42 -0400 Subject: [PATCH 05/16] feat: add onboarding, branding, and supporting data models, migrations, and schemas Add onboarding_dismissed and branding columns (logo_data, logo_content_type, company_display_name) to users and teams models. Create SessionSupportingData model for attaching text snippets and screenshots to sessions. Add Pydantic schemas for onboarding status, branding responses, and supporting data CRUD. Update SessionExport to accept pdf format. Co-Authored-By: Claude Opus 4.6 (1M context) --- ...d05_add_onboarding_and_branding_columns.py | 41 +++++++++++++++++++ ...dd18c_add_session_supporting_data_table.py | 41 +++++++++++++++++++ backend/app/models/__init__.py | 2 + backend/app/models/session.py | 1 + backend/app/models/supporting_data.py | 25 +++++++++++ backend/app/models/team.py | 10 ++++- backend/app/models/user.py | 11 ++++- backend/app/schemas/branding.py | 14 +++++++ backend/app/schemas/onboarding.py | 12 ++++++ backend/app/schemas/session.py | 2 +- backend/app/schemas/supporting_data.py | 30 ++++++++++++++ 11 files changed, 185 insertions(+), 4 deletions(-) create mode 100644 backend/alembic/versions/21ddb46ddd05_add_onboarding_and_branding_columns.py create mode 100644 backend/alembic/versions/ee98013dd18c_add_session_supporting_data_table.py create mode 100644 backend/app/models/supporting_data.py create mode 100644 backend/app/schemas/branding.py create mode 100644 backend/app/schemas/onboarding.py create mode 100644 backend/app/schemas/supporting_data.py diff --git a/backend/alembic/versions/21ddb46ddd05_add_onboarding_and_branding_columns.py b/backend/alembic/versions/21ddb46ddd05_add_onboarding_and_branding_columns.py new file mode 100644 index 00000000..4627141a --- /dev/null +++ b/backend/alembic/versions/21ddb46ddd05_add_onboarding_and_branding_columns.py @@ -0,0 +1,41 @@ +"""add onboarding and branding columns + +Revision ID: 21ddb46ddd05 +Revises: 061 +Create Date: 2026-03-16 23:30:48.910485 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '21ddb46ddd05' +down_revision: Union[str, None] = '061' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Users: onboarding + branding columns + op.add_column('users', sa.Column('onboarding_dismissed', sa.Boolean(), server_default='false', nullable=False)) + op.add_column('users', sa.Column('logo_data', sa.Text(), nullable=True)) + op.add_column('users', sa.Column('logo_content_type', sa.String(length=50), nullable=True)) + op.add_column('users', sa.Column('company_display_name', sa.String(length=255), nullable=True)) + + # Teams: branding columns + op.add_column('teams', sa.Column('logo_data', sa.Text(), nullable=True)) + op.add_column('teams', sa.Column('logo_content_type', sa.String(length=50), nullable=True)) + op.add_column('teams', sa.Column('company_display_name', sa.String(length=255), nullable=True)) + + +def downgrade() -> None: + op.drop_column('teams', 'company_display_name') + op.drop_column('teams', 'logo_content_type') + op.drop_column('teams', 'logo_data') + op.drop_column('users', 'company_display_name') + op.drop_column('users', 'logo_content_type') + op.drop_column('users', 'logo_data') + op.drop_column('users', 'onboarding_dismissed') diff --git a/backend/alembic/versions/ee98013dd18c_add_session_supporting_data_table.py b/backend/alembic/versions/ee98013dd18c_add_session_supporting_data_table.py new file mode 100644 index 00000000..9a4ecdef --- /dev/null +++ b/backend/alembic/versions/ee98013dd18c_add_session_supporting_data_table.py @@ -0,0 +1,41 @@ +"""add session_supporting_data table + +Revision ID: ee98013dd18c +Revises: 21ddb46ddd05 +Create Date: 2026-03-16 23:31:43.483511 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'ee98013dd18c' +down_revision: Union[str, None] = '21ddb46ddd05' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table('session_supporting_data', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('session_id', sa.UUID(), nullable=False), + sa.Column('label', sa.String(length=255), nullable=False), + sa.Column('data_type', sa.Enum('text_snippet', 'screenshot', name='supporting_data_type'), nullable=False), + sa.Column('content', sa.Text(), nullable=False), + sa.Column('content_type', sa.String(length=50), nullable=True), + sa.Column('sort_order', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['session_id'], ['sessions.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + ) + op.create_index(op.f('ix_session_supporting_data_session_id'), 'session_supporting_data', ['session_id'], unique=False) + + +def downgrade() -> None: + op.drop_index(op.f('ix_session_supporting_data_session_id'), table_name='session_supporting_data') + op.drop_table('session_supporting_data') + op.execute("DROP TYPE IF EXISTS supporting_data_type") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 1938cdf0..b7854b0b 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -39,6 +39,7 @@ from .script_template import ScriptCategory, ScriptTemplate, ScriptGeneration from .psa_connection import PsaConnection from .psa_post_log import PsaPostLog from .psa_member_mapping import PsaMemberMapping +from .supporting_data import SessionSupportingData __all__ = [ "User", @@ -92,4 +93,5 @@ __all__ = [ "PsaConnection", "PsaPostLog", "PsaMemberMapping", + "SessionSupportingData", ] diff --git a/backend/app/models/session.py b/backend/app/models/session.py index bbab74cf..c191572b 100644 --- a/backend/app/models/session.py +++ b/backend/app/models/session.py @@ -82,6 +82,7 @@ class Session(Base): assigned_to: Mapped[Optional["User"]] = relationship("User", foreign_keys=[assigned_to_id]) attachments: Mapped[list["Attachment"]] = relationship("Attachment", back_populates="session") shares: Mapped[list["SessionShare"]] = relationship("SessionShare", back_populates="session", cascade="all, delete-orphan") + supporting_data = relationship("SessionSupportingData", back_populates="session", cascade="all, delete-orphan", order_by="SessionSupportingData.sort_order") # PSA ticket link psa_ticket_id: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) diff --git a/backend/app/models/supporting_data.py b/backend/app/models/supporting_data.py new file mode 100644 index 00000000..ea04cd91 --- /dev/null +++ b/backend/app/models/supporting_data.py @@ -0,0 +1,25 @@ +import uuid +from datetime import datetime, timezone +from typing import Optional + +from sqlalchemy import DateTime, Enum, ForeignKey, Integer, String, Text +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.core.database import Base + + +class SessionSupportingData(Base): + __tablename__ = "session_supporting_data" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + session_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("sessions.id", ondelete="CASCADE"), nullable=False, index=True) + label: Mapped[str] = mapped_column(String(255), nullable=False) + data_type: Mapped[str] = mapped_column(Enum("text_snippet", "screenshot", name="supporting_data_type"), nullable=False) + content: Mapped[str] = mapped_column(Text, nullable=False) + content_type: Mapped[Optional[str]] = mapped_column(String(50), nullable=True) + sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False) + + session = relationship("Session", back_populates="supporting_data") diff --git a/backend/app/models/team.py b/backend/app/models/team.py index 299c9cb9..098444dd 100644 --- a/backend/app/models/team.py +++ b/backend/app/models/team.py @@ -1,7 +1,7 @@ import uuid from datetime import datetime, timezone -from typing import TYPE_CHECKING -from sqlalchemy import String, DateTime +from typing import Optional, TYPE_CHECKING +from sqlalchemy import String, DateTime, Text from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.dialects.postgresql import UUID from app.core.database import Base @@ -23,6 +23,12 @@ class Team(Base): default=uuid.uuid4 ) name: Mapped[str] = mapped_column(String(255), nullable=False) + + # Branding + logo_data: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + logo_content_type: Mapped[Optional[str]] = mapped_column(String(50), nullable=True) + company_display_name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) + created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) diff --git a/backend/app/models/user.py b/backend/app/models/user.py index f0c3f3f6..c7d566d7 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -1,7 +1,7 @@ import uuid from datetime import datetime, timezone from typing import Optional, TYPE_CHECKING -from sqlalchemy import String, DateTime, ForeignKey, Boolean, CheckConstraint +from sqlalchemy import String, DateTime, ForeignKey, Boolean, CheckConstraint, Text from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.dialects.postgresql import UUID from app.core.database import Base @@ -73,6 +73,15 @@ class User(Base): job_title: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) timezone: Mapped[str] = mapped_column(String(100), nullable=False, default="UTC", server_default="UTC") avatar_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) + + # Onboarding + onboarding_dismissed: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, server_default="false") + + # Branding (solo pros without a team) + logo_data: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + logo_content_type: Mapped[Optional[str]] = mapped_column(String(50), nullable=True) + company_display_name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) + email_verified_at: Mapped[Optional[datetime]] = mapped_column( DateTime(timezone=True), nullable=True ) diff --git a/backend/app/schemas/branding.py b/backend/app/schemas/branding.py new file mode 100644 index 00000000..bd6baeaf --- /dev/null +++ b/backend/app/schemas/branding.py @@ -0,0 +1,14 @@ +from typing import Optional +from pydantic import BaseModel + + +class BrandingResponse(BaseModel): + company_display_name: Optional[str] = None + logo_content_type: Optional[str] = None + has_logo: bool = False + + +class BrandingLogoResponse(BaseModel): + company_display_name: Optional[str] = None + logo_data: Optional[str] = None + logo_content_type: Optional[str] = None diff --git a/backend/app/schemas/onboarding.py b/backend/app/schemas/onboarding.py new file mode 100644 index 00000000..d21647b5 --- /dev/null +++ b/backend/app/schemas/onboarding.py @@ -0,0 +1,12 @@ +from pydantic import BaseModel + + +class OnboardingStatus(BaseModel): + created_flow: bool + ran_session: bool + exported_session: bool + tried_ai_assistant: bool + invited_teammate: bool + connected_psa: bool + is_team_user: bool + dismissed: bool diff --git a/backend/app/schemas/session.py b/backend/app/schemas/session.py index 58f4e2df..2a764711 100644 --- a/backend/app/schemas/session.py +++ b/backend/app/schemas/session.py @@ -106,7 +106,7 @@ class SessionResponse(BaseModel): class SessionExport(BaseModel): - format: str = Field(default="markdown", pattern="^(text|markdown|html|psa)$") + format: str = Field(default="markdown", pattern="^(text|markdown|html|psa|pdf)$") include_timestamps: bool = True include_tree_info: bool = True # Phase A diff --git a/backend/app/schemas/supporting_data.py b/backend/app/schemas/supporting_data.py new file mode 100644 index 00000000..7469fab8 --- /dev/null +++ b/backend/app/schemas/supporting_data.py @@ -0,0 +1,30 @@ +from datetime import datetime +from typing import Literal, Optional +from uuid import UUID +from pydantic import BaseModel, Field + + +class SupportingDataCreate(BaseModel): + label: str = Field(..., min_length=1, max_length=255) + data_type: Literal["text_snippet", "screenshot"] + content: str = Field(..., min_length=1, max_length=5_000_000) + content_type: Optional[str] = Field(None, max_length=50) + + +class SupportingDataUpdate(BaseModel): + label: Optional[str] = Field(None, min_length=1, max_length=255) + content: Optional[str] = Field(None, min_length=1) + + +class SupportingDataResponse(BaseModel): + id: UUID + session_id: UUID + label: str + data_type: str + content: str + content_type: Optional[str] + sort_order: int + created_at: datetime + updated_at: datetime + + model_config = {"from_attributes": True} -- 2.49.1 From 75b32a4f5aeacdcdf527108c9cb9c547a0edb36b Mon Sep 17 00:00:00 2001 From: chihlasm Date: Tue, 17 Mar 2026 00:05:28 -0400 Subject: [PATCH 06/16] feat: add onboarding status and dismiss endpoints with tests Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/app/api/endpoints/onboarding.py | 110 ++++++++++++++++++++++++ backend/app/api/router.py | 2 + backend/tests/test_onboarding.py | 72 ++++++++++++++++ 3 files changed, 184 insertions(+) create mode 100644 backend/app/api/endpoints/onboarding.py create mode 100644 backend/tests/test_onboarding.py diff --git a/backend/app/api/endpoints/onboarding.py b/backend/app/api/endpoints/onboarding.py new file mode 100644 index 00000000..fdb07cd8 --- /dev/null +++ b/backend/app/api/endpoints/onboarding.py @@ -0,0 +1,110 @@ +"""Onboarding status endpoints — track new-user checklist progress.""" + +from typing import Annotated + +from fastapi import APIRouter, Depends +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_current_active_user +from app.core.database import get_db +from app.models.assistant_chat import AssistantChat +from app.models.psa_connection import PsaConnection +from app.models.session import Session +from app.models.tree import Tree +from app.models.user import User +from app.schemas.onboarding import OnboardingStatus + +router = APIRouter(prefix="/users", tags=["onboarding"]) + + +@router.get("/onboarding-status", response_model=OnboardingStatus) +async def get_onboarding_status( + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_active_user)], +) -> OnboardingStatus: + """Return onboarding checklist completion status for the current user.""" + + # created_flow — user owns at least 1 tree + created_flow_q = await db.execute( + select(func.count()).select_from(Tree).where(Tree.author_id == current_user.id).limit(1) + ) + created_flow = (created_flow_q.scalar() or 0) > 0 + + # ran_session — user has at least 1 session + ran_session_q = await db.execute( + select(func.count()).select_from(Session).where(Session.user_id == current_user.id).limit(1) + ) + ran_session = (ran_session_q.scalar() or 0) > 0 + + # exported_session — user has at least 1 session with exported=True + exported_q = await db.execute( + select(func.count()) + .select_from(Session) + .where(Session.user_id == current_user.id, Session.exported == True) # noqa: E712 + .limit(1) + ) + exported_session = (exported_q.scalar() or 0) > 0 + + # tried_ai_assistant — user has at least 1 assistant chat + ai_q = await db.execute( + select(func.count()) + .select_from(AssistantChat) + .where(AssistantChat.user_id == current_user.id) + .limit(1) + ) + tried_ai_assistant = (ai_q.scalar() or 0) > 0 + + # Team-dependent checks + is_team_user = current_user.team_id is not None + + invited_teammate = False + connected_psa = False + + if is_team_user: + # invited_teammate — team/account has more than 1 member + if current_user.account_id: + teammate_q = await db.execute( + select(func.count()) + .select_from(User) + .where( + User.account_id == current_user.account_id, + User.deleted_at.is_(None), + ) + ) + invited_teammate = (teammate_q.scalar() or 0) > 1 + + # connected_psa — account has at least 1 PSA connection + if current_user.account_id: + psa_q = await db.execute( + select(func.count()) + .select_from(PsaConnection) + .where(PsaConnection.account_id == current_user.account_id) + .limit(1) + ) + connected_psa = (psa_q.scalar() or 0) > 0 + + return OnboardingStatus( + created_flow=created_flow, + ran_session=ran_session, + exported_session=exported_session, + tried_ai_assistant=tried_ai_assistant, + invited_teammate=invited_teammate, + connected_psa=connected_psa, + is_team_user=is_team_user, + dismissed=current_user.onboarding_dismissed, + ) + + +@router.post("/onboarding-status/dismiss", response_model=OnboardingStatus) +async def dismiss_onboarding( + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_active_user)], +) -> OnboardingStatus: + """Dismiss the onboarding checklist for the current user.""" + current_user.onboarding_dismissed = True + await db.commit() + await db.refresh(current_user) + + # Return updated status (reuse the GET logic) + return await get_onboarding_status(db=db, current_user=current_user) diff --git a/backend/app/api/router.py b/backend/app/api/router.py index 3bc9afde..c8d96850 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -18,6 +18,7 @@ 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 api_router = APIRouter() @@ -61,3 +62,4 @@ 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) diff --git a/backend/tests/test_onboarding.py b/backend/tests/test_onboarding.py new file mode 100644 index 00000000..aa4f48d8 --- /dev/null +++ b/backend/tests/test_onboarding.py @@ -0,0 +1,72 @@ +"""Tests for onboarding status endpoints.""" + +import pytest + + +@pytest.mark.asyncio +async def test_onboarding_status_fresh_user(client, auth_headers): + """Fresh user should have all onboarding items false.""" + response = await client.get( + "/api/v1/users/onboarding-status", + headers=auth_headers, + ) + assert response.status_code == 200 + data = response.json() + + assert data["created_flow"] is False + assert data["ran_session"] is False + assert data["exported_session"] is False + assert data["tried_ai_assistant"] is False + assert data["invited_teammate"] is False + assert data["connected_psa"] is False + assert data["is_team_user"] is False + assert data["dismissed"] is False + + +@pytest.mark.asyncio +async def test_onboarding_dismiss(client, auth_headers): + """Dismiss endpoint should set dismissed to true.""" + # Verify starts as false + response = await client.get( + "/api/v1/users/onboarding-status", + headers=auth_headers, + ) + assert response.status_code == 200 + assert response.json()["dismissed"] is False + + # Dismiss + response = await client.post( + "/api/v1/users/onboarding-status/dismiss", + headers=auth_headers, + ) + assert response.status_code == 200 + assert response.json()["dismissed"] is True + + # Verify persisted + response = await client.get( + "/api/v1/users/onboarding-status", + headers=auth_headers, + ) + assert response.status_code == 200 + assert response.json()["dismissed"] is True + + +@pytest.mark.asyncio +async def test_onboarding_created_flow_after_tree_creation(client, auth_headers, test_tree): + """After creating a tree, created_flow should be true.""" + response = await client.get( + "/api/v1/users/onboarding-status", + headers=auth_headers, + ) + assert response.status_code == 200 + assert response.json()["created_flow"] is True + + +@pytest.mark.asyncio +async def test_onboarding_requires_auth(client): + """Unauthenticated requests should be rejected.""" + response = await client.get("/api/v1/users/onboarding-status") + assert response.status_code == 401 + + response = await client.post("/api/v1/users/onboarding-status/dismiss") + assert response.status_code == 401 -- 2.49.1 From fdeb4171313efb4706356c361bb2f3024f1cd87c Mon Sep 17 00:00:00 2001 From: chihlasm Date: Tue, 17 Mar 2026 00:12:00 -0400 Subject: [PATCH 07/16] feat: add team branding CRUD endpoints with tests Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/app/api/endpoints/branding.py | 130 ++++++++++++++ backend/app/api/router.py | 2 + backend/tests/test_branding.py | 249 ++++++++++++++++++++++++++ 3 files changed, 381 insertions(+) create mode 100644 backend/app/api/endpoints/branding.py create mode 100644 backend/tests/test_branding.py diff --git a/backend/app/api/endpoints/branding.py b/backend/app/api/endpoints/branding.py new file mode 100644 index 00000000..7e22b2f7 --- /dev/null +++ b/backend/app/api/endpoints/branding.py @@ -0,0 +1,130 @@ +"""Team branding endpoints — logo upload and company display name.""" + +import base64 +from typing import Annotated, Optional +from uuid import UUID + +from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_current_active_user, get_db +from app.models.team import Team +from app.models.user import User +from app.schemas.branding import BrandingResponse + +router = APIRouter(prefix="/teams", tags=["branding"]) + +ALLOWED_CONTENT_TYPES = {"image/png", "image/jpeg", "image/svg+xml"} +MAX_LOGO_SIZE = 2 * 1024 * 1024 # 2MB + + +def _require_team_member(user: User, team_id: UUID) -> None: + """Ensure the user belongs to the given team (or is super admin).""" + if user.is_super_admin: + return + if user.team_id != team_id: + raise HTTPException(status_code=403, detail="Not a member of this team") + + +def _require_team_admin(user: User, team_id: UUID) -> None: + """Ensure the user is a team admin for the given team (or is super admin).""" + if user.is_super_admin: + return + if not user.is_team_admin or user.team_id != team_id: + raise HTTPException(status_code=403, detail="Team admin required") + + +@router.get("/{team_id}/branding", response_model=BrandingResponse) +async def get_branding( + team_id: UUID, + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_active_user)], +) -> BrandingResponse: + """Retrieve branding info for a team. Any team member can read.""" + _require_team_member(current_user, team_id) + + result = await db.execute(select(Team).where(Team.id == team_id)) + team = result.scalar_one_or_none() + if not team: + raise HTTPException(status_code=404, detail="Team not found") + + return BrandingResponse( + company_display_name=team.company_display_name, + logo_content_type=team.logo_content_type, + has_logo=team.logo_data is not None, + ) + + +@router.patch("/{team_id}/branding", response_model=BrandingResponse) +async def update_branding( + team_id: UUID, + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_active_user)], + logo: Annotated[Optional[UploadFile], File()] = None, + company_display_name: Annotated[Optional[str], Form()] = None, +) -> BrandingResponse: + """Upload logo and/or update company display name. Team admin only.""" + _require_team_admin(current_user, team_id) + + result = await db.execute(select(Team).where(Team.id == team_id)) + team = result.scalar_one_or_none() + if not team: + raise HTTPException(status_code=404, detail="Team not found") + + # Handle logo upload + if logo is not None: + if logo.content_type not in ALLOWED_CONTENT_TYPES: + raise HTTPException( + status_code=400, + detail=f"Invalid content type '{logo.content_type}'. Allowed: {', '.join(sorted(ALLOWED_CONTENT_TYPES))}", + ) + + raw_bytes = await logo.read() + if len(raw_bytes) > MAX_LOGO_SIZE: + raise HTTPException( + status_code=400, + detail=f"Logo exceeds maximum size of {MAX_LOGO_SIZE // (1024 * 1024)}MB", + ) + + team.logo_data = base64.b64encode(raw_bytes).decode("utf-8") + team.logo_content_type = logo.content_type + + # Handle display name update + if company_display_name is not None: + team.company_display_name = company_display_name + + await db.commit() + await db.refresh(team) + + return BrandingResponse( + company_display_name=team.company_display_name, + logo_content_type=team.logo_content_type, + has_logo=team.logo_data is not None, + ) + + +@router.delete("/{team_id}/branding/logo", status_code=200, response_model=BrandingResponse) +async def delete_logo( + team_id: UUID, + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_active_user)], +) -> BrandingResponse: + """Remove the team logo. Team admin only.""" + _require_team_admin(current_user, team_id) + + result = await db.execute(select(Team).where(Team.id == team_id)) + team = result.scalar_one_or_none() + if not team: + raise HTTPException(status_code=404, detail="Team not found") + + team.logo_data = None + team.logo_content_type = None + await db.commit() + await db.refresh(team) + + return BrandingResponse( + company_display_name=team.company_display_name, + logo_content_type=team.logo_content_type, + has_logo=False, + ) diff --git a/backend/app/api/router.py b/backend/app/api/router.py index c8d96850..c5ee9ddd 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -19,6 +19,7 @@ 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 api_router = APIRouter() @@ -63,3 +64,4 @@ 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) diff --git a/backend/tests/test_branding.py b/backend/tests/test_branding.py new file mode 100644 index 00000000..c94c696a --- /dev/null +++ b/backend/tests/test_branding.py @@ -0,0 +1,249 @@ +"""Tests for team branding endpoints (logo upload + company display name).""" + +import uuid + +import pytest +from httpx import AsyncClient +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select + +from app.core.security import get_password_hash +from app.models.team import Team +from app.models.user import User + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +async def _create_team_with_admin( + test_db: AsyncSession, + client: AsyncClient, + *, + team_name: str = "Branding Test Team", +) -> tuple[dict, str, Team]: + """Create a team + team admin user. Returns (auth_headers, team_id_str, team).""" + team = Team(name=team_name) + test_db.add(team) + await test_db.flush() + + email = f"admin_{uuid.uuid4().hex[:8]}@test.com" + user = User( + email=email, + password_hash=get_password_hash("Password123!"), + name="Team Admin", + is_active=True, + team_id=team.id, + is_team_admin=True, + role="engineer", + ) + test_db.add(user) + await test_db.commit() + + resp = await client.post( + "/api/v1/auth/login/json", + json={"email": email, "password": "Password123!"}, + ) + assert resp.status_code == 200 + token = resp.json()["access_token"] + headers = {"Authorization": f"Bearer {token}"} + return headers, str(team.id), team + + +async def _create_team_member( + test_db: AsyncSession, + client: AsyncClient, + team: Team, + *, + is_team_admin: bool = False, +) -> dict: + """Create a regular team member. Returns auth_headers.""" + email = f"member_{uuid.uuid4().hex[:8]}@test.com" + user = User( + email=email, + password_hash=get_password_hash("Password123!"), + name="Team Member", + is_active=True, + team_id=team.id, + is_team_admin=is_team_admin, + role="engineer", + ) + test_db.add(user) + await test_db.commit() + + resp = await client.post( + "/api/v1/auth/login/json", + json={"email": email, "password": "Password123!"}, + ) + assert resp.status_code == 200 + token = resp.json()["access_token"] + return {"Authorization": f"Bearer {token}"} + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_get_branding_defaults(client: AsyncClient, test_db: AsyncSession): + """GET branding with no logo returns defaults (has_logo=False).""" + headers, team_id, _ = await _create_team_with_admin(test_db, client) + + resp = await client.get(f"/api/v1/teams/{team_id}/branding", headers=headers) + assert resp.status_code == 200 + data = resp.json() + assert data["has_logo"] is False + assert data["company_display_name"] is None + assert data["logo_content_type"] is None + + +@pytest.mark.asyncio +async def test_upload_logo_with_company_name(client: AsyncClient, test_db: AsyncSession): + """PATCH with valid PNG logo + company name succeeds.""" + headers, team_id, _ = await _create_team_with_admin(test_db, client) + + # 1x1 transparent PNG (67 bytes) + png_bytes = ( + b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01" + b"\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89" + b"\x00\x00\x00\nIDATx\x9cc\x00\x01\x00\x00\x05\x00\x01" + b"\r\n\xb4\x00\x00\x00\x00IEND\xaeB`\x82" + ) + + resp = await client.patch( + f"/api/v1/teams/{team_id}/branding", + headers=headers, + files={"logo": ("logo.png", png_bytes, "image/png")}, + data={"company_display_name": "Acme MSP"}, + ) + assert resp.status_code == 200, resp.text + data = resp.json() + assert data["has_logo"] is True + assert data["logo_content_type"] == "image/png" + assert data["company_display_name"] == "Acme MSP" + + +@pytest.mark.asyncio +async def test_upload_oversized_logo(client: AsyncClient, test_db: AsyncSession): + """PATCH with >2MB file returns 400.""" + headers, team_id, _ = await _create_team_with_admin(test_db, client) + + big_bytes = b"\x00" * (2 * 1024 * 1024 + 1) # 2MB + 1 byte + + resp = await client.patch( + f"/api/v1/teams/{team_id}/branding", + headers=headers, + files={"logo": ("big.png", big_bytes, "image/png")}, + ) + assert resp.status_code == 400 + assert "maximum size" in resp.json()["detail"].lower() + + +@pytest.mark.asyncio +async def test_upload_invalid_content_type(client: AsyncClient, test_db: AsyncSession): + """PATCH with application/pdf content type returns 400.""" + headers, team_id, _ = await _create_team_with_admin(test_db, client) + + resp = await client.patch( + f"/api/v1/teams/{team_id}/branding", + headers=headers, + files={"logo": ("doc.pdf", b"%PDF-fake", "application/pdf")}, + ) + assert resp.status_code == 400 + assert "content type" in resp.json()["detail"].lower() + + +@pytest.mark.asyncio +async def test_delete_logo(client: AsyncClient, test_db: AsyncSession): + """DELETE logo clears logo_data while keeping company_display_name.""" + headers, team_id, _ = await _create_team_with_admin(test_db, client) + + # Upload a logo + name first + png_bytes = b"\x89PNG\r\n\x1a\n" + b"\x00" * 50 + await client.patch( + f"/api/v1/teams/{team_id}/branding", + headers=headers, + files={"logo": ("logo.png", png_bytes, "image/png")}, + data={"company_display_name": "Keep This Name"}, + ) + + # Delete logo + resp = await client.delete( + f"/api/v1/teams/{team_id}/branding/logo", + headers=headers, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["has_logo"] is False + assert data["logo_content_type"] is None + assert data["company_display_name"] == "Keep This Name" + + +@pytest.mark.asyncio +async def test_non_admin_cannot_update(client: AsyncClient, test_db: AsyncSession): + """Regular team member (non-admin) cannot PATCH branding — returns 403.""" + admin_headers, team_id, team = await _create_team_with_admin(test_db, client) + member_headers = await _create_team_member(test_db, client, team) + + resp = await client.patch( + f"/api/v1/teams/{team_id}/branding", + headers=member_headers, + data={"company_display_name": "Should Fail"}, + ) + assert resp.status_code == 403 + + +@pytest.mark.asyncio +async def test_non_admin_cannot_delete_logo(client: AsyncClient, test_db: AsyncSession): + """Regular team member cannot DELETE logo — returns 403.""" + admin_headers, team_id, team = await _create_team_with_admin(test_db, client) + member_headers = await _create_team_member(test_db, client, team) + + resp = await client.delete( + f"/api/v1/teams/{team_id}/branding/logo", + headers=member_headers, + ) + assert resp.status_code == 403 + + +@pytest.mark.asyncio +async def test_non_member_cannot_read(client: AsyncClient, test_db: AsyncSession): + """User from a different team cannot GET branding — returns 403.""" + _, team_id, _ = await _create_team_with_admin(test_db, client, team_name="Team A") + other_headers, _, _ = await _create_team_with_admin(test_db, client, team_name="Team B") + + resp = await client.get( + f"/api/v1/teams/{team_id}/branding", + headers=other_headers, + ) + assert resp.status_code == 403 + + +@pytest.mark.asyncio +async def test_member_can_read_branding(client: AsyncClient, test_db: AsyncSession): + """Regular team member CAN read branding.""" + admin_headers, team_id, team = await _create_team_with_admin(test_db, client) + member_headers = await _create_team_member(test_db, client, team) + + resp = await client.get( + f"/api/v1/teams/{team_id}/branding", + headers=member_headers, + ) + assert resp.status_code == 200 + assert resp.json()["has_logo"] is False + + +@pytest.mark.asyncio +async def test_update_display_name_only(client: AsyncClient, test_db: AsyncSession): + """PATCH with only company_display_name (no logo) succeeds.""" + headers, team_id, _ = await _create_team_with_admin(test_db, client) + + resp = await client.patch( + f"/api/v1/teams/{team_id}/branding", + headers=headers, + data={"company_display_name": "Just A Name"}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["company_display_name"] == "Just A Name" + assert data["has_logo"] is False -- 2.49.1 From 2c11917b5a8d8ce701ae3e487459735cc6935e14 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Tue, 17 Mar 2026 00:22:40 -0400 Subject: [PATCH 08/16] feat: add supporting data CRUD endpoints with tests Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/app/api/endpoints/supporting_data.py | 201 +++++++++++++++++ backend/app/api/router.py | 2 + backend/tests/test_supporting_data.py | 217 +++++++++++++++++++ 3 files changed, 420 insertions(+) create mode 100644 backend/app/api/endpoints/supporting_data.py create mode 100644 backend/tests/test_supporting_data.py diff --git a/backend/app/api/endpoints/supporting_data.py b/backend/app/api/endpoints/supporting_data.py new file mode 100644 index 00000000..b7d0a33a --- /dev/null +++ b/backend/app/api/endpoints/supporting_data.py @@ -0,0 +1,201 @@ +import base64 +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import get_db +from app.api.deps import get_current_active_user +from app.models import User +from app.models.session import Session +from app.models.supporting_data import SessionSupportingData +from app.schemas.supporting_data import ( + SupportingDataCreate, + SupportingDataUpdate, + SupportingDataResponse, +) + +router = APIRouter(prefix="/sessions", tags=["supporting-data"]) + +MAX_ITEMS_PER_SESSION = 20 +MAX_TEXT_SNIPPET_CHARS = 50_000 +MAX_SCREENSHOT_RAW_BYTES = 2 * 1024 * 1024 # 2MB + + +async def _check_session_access(user: User, session: Session, db: AsyncSession) -> None: + """Verify user has access to the session (owner, team admin, or super admin).""" + if user.is_super_admin: + return + if session.user_id == user.id: + return + # Team admins can only access sessions from their own team members + if user.is_team_admin and user.team_id is not None: + session_owner = await db.get(User, session.user_id) + if session_owner and session_owner.team_id == user.team_id: + return + raise HTTPException(status_code=403, detail="Access denied") + + +async def _get_session_or_404(session_id: UUID, db: AsyncSession) -> Session: + """Fetch session by ID or raise 404.""" + result = await db.execute(select(Session).where(Session.id == session_id)) + session = result.scalar_one_or_none() + if not session: + raise HTTPException(status_code=404, detail="Session not found") + return session + + +@router.post( + "/{session_id}/supporting-data", + response_model=SupportingDataResponse, + status_code=status.HTTP_201_CREATED, +) +async def create_supporting_data( + session_id: UUID, + data: SupportingDataCreate, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_active_user), +): + """Add a supporting data item (text snippet or screenshot) to a session.""" + session = await _get_session_or_404(session_id, db) + await _check_session_access(current_user, session, db) + + # Check item limit + count_result = await db.execute( + select(func.count()).select_from(SessionSupportingData).where( + SessionSupportingData.session_id == session_id + ) + ) + current_count = count_result.scalar() or 0 + if current_count >= MAX_ITEMS_PER_SESSION: + raise HTTPException( + status_code=400, + detail=f"Maximum {MAX_ITEMS_PER_SESSION} supporting data items per session", + ) + + # Validate content size based on type + if data.data_type == "text_snippet": + if len(data.content) > MAX_TEXT_SNIPPET_CHARS: + raise HTTPException( + status_code=400, + detail=f"Text snippet exceeds maximum {MAX_TEXT_SNIPPET_CHARS} characters", + ) + elif data.data_type == "screenshot": + try: + raw_bytes = base64.b64decode(data.content) + except Exception: + raise HTTPException(status_code=400, detail="Invalid base64 content for screenshot") + if len(raw_bytes) > MAX_SCREENSHOT_RAW_BYTES: + raise HTTPException( + status_code=400, + detail=f"Screenshot exceeds maximum {MAX_SCREENSHOT_RAW_BYTES // (1024 * 1024)}MB raw size", + ) + + # Auto-increment sort_order + max_order_result = await db.execute( + select(func.max(SessionSupportingData.sort_order)).where( + SessionSupportingData.session_id == session_id + ) + ) + max_order = max_order_result.scalar() + next_order = (max_order or 0) + 1 + + item = SessionSupportingData( + session_id=session_id, + label=data.label, + data_type=data.data_type, + content=data.content, + content_type=data.content_type, + sort_order=next_order, + ) + db.add(item) + await db.commit() + await db.refresh(item) + + return item + + +@router.get( + "/{session_id}/supporting-data", + response_model=list[SupportingDataResponse], +) +async def list_supporting_data( + session_id: UUID, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_active_user), +): + """List all supporting data items for a session, ordered by sort_order.""" + session = await _get_session_or_404(session_id, db) + await _check_session_access(current_user, session, db) + + result = await db.execute( + select(SessionSupportingData) + .where(SessionSupportingData.session_id == session_id) + .order_by(SessionSupportingData.sort_order) + ) + return result.scalars().all() + + +@router.patch( + "/{session_id}/supporting-data/{item_id}", + response_model=SupportingDataResponse, +) +async def update_supporting_data( + session_id: UUID, + item_id: UUID, + data: SupportingDataUpdate, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_active_user), +): + """Update a supporting data item's label or content.""" + session = await _get_session_or_404(session_id, db) + await _check_session_access(current_user, session, db) + + result = await db.execute( + select(SessionSupportingData).where( + SessionSupportingData.id == item_id, + SessionSupportingData.session_id == session_id, + ) + ) + item = result.scalar_one_or_none() + if not item: + raise HTTPException(status_code=404, detail="Supporting data item not found") + + if data.label is not None: + item.label = data.label + if data.content is not None: + item.content = data.content + + await db.commit() + await db.refresh(item) + + return item + + +@router.delete( + "/{session_id}/supporting-data/{item_id}", + status_code=status.HTTP_204_NO_CONTENT, +) +async def delete_supporting_data( + session_id: UUID, + item_id: UUID, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_active_user), +): + """Remove a supporting data item from a session.""" + session = await _get_session_or_404(session_id, db) + await _check_session_access(current_user, session, db) + + result = await db.execute( + select(SessionSupportingData).where( + SessionSupportingData.id == item_id, + SessionSupportingData.session_id == session_id, + ) + ) + item = result.scalar_one_or_none() + if not item: + raise HTTPException(status_code=404, detail="Supporting data item not found") + + await db.delete(item) + await db.commit() diff --git a/backend/app/api/router.py b/backend/app/api/router.py index c5ee9ddd..e857bd20 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -20,6 +20,7 @@ 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() @@ -65,3 +66,4 @@ api_router.include_router(scripts.router) api_router.include_router(integrations.router) api_router.include_router(onboarding.router) api_router.include_router(branding.router) +api_router.include_router(supporting_data.router) diff --git a/backend/tests/test_supporting_data.py b/backend/tests/test_supporting_data.py new file mode 100644 index 00000000..dc5dd4db --- /dev/null +++ b/backend/tests/test_supporting_data.py @@ -0,0 +1,217 @@ +import base64 +import pytest +from httpx import AsyncClient + +pytestmark = pytest.mark.asyncio + + +@pytest.fixture +async def test_session(client: AsyncClient, auth_headers: dict, test_tree: dict): + """Create a test session from the test tree.""" + response = await client.post( + "/api/v1/sessions", + json={"tree_id": test_tree["id"]}, + headers=auth_headers, + ) + assert response.status_code == 201, f"Failed to create session: {response.text}" + return response.json() + + +# --- Create --- + + +async def test_create_text_snippet(client: AsyncClient, auth_headers: dict, test_session: dict): + """Create a text snippet supporting data item — returns 201.""" + response = await client.post( + f"/api/v1/sessions/{test_session['id']}/supporting-data", + json={ + "label": "Error log", + "data_type": "text_snippet", + "content": "NullReferenceException at line 42", + }, + headers=auth_headers, + ) + assert response.status_code == 201 + data = response.json() + assert data["label"] == "Error log" + assert data["data_type"] == "text_snippet" + assert data["content"] == "NullReferenceException at line 42" + assert data["sort_order"] == 1 + assert data["session_id"] == test_session["id"] + + +async def test_create_screenshot(client: AsyncClient, auth_headers: dict, test_session: dict): + """Create a screenshot supporting data item — returns 201.""" + # Small valid base64 content (a tiny PNG-like payload) + small_content = base64.b64encode(b"\x89PNG\r\n\x1a\n" + b"\x00" * 100).decode() + response = await client.post( + f"/api/v1/sessions/{test_session['id']}/supporting-data", + json={ + "label": "Error screenshot", + "data_type": "screenshot", + "content": small_content, + "content_type": "image/png", + }, + headers=auth_headers, + ) + assert response.status_code == 201 + data = response.json() + assert data["label"] == "Error screenshot" + assert data["data_type"] == "screenshot" + assert data["content_type"] == "image/png" + + +# --- List --- + + +async def test_list_items_in_sort_order(client: AsyncClient, auth_headers: dict, test_session: dict): + """List returns items ordered by sort_order.""" + # Create 3 items + for i in range(3): + resp = await client.post( + f"/api/v1/sessions/{test_session['id']}/supporting-data", + json={ + "label": f"Item {i}", + "data_type": "text_snippet", + "content": f"Content {i}", + }, + headers=auth_headers, + ) + assert resp.status_code == 201 + + response = await client.get( + f"/api/v1/sessions/{test_session['id']}/supporting-data", + headers=auth_headers, + ) + assert response.status_code == 200 + items = response.json() + assert len(items) == 3 + assert items[0]["label"] == "Item 0" + assert items[1]["label"] == "Item 1" + assert items[2]["label"] == "Item 2" + assert items[0]["sort_order"] < items[1]["sort_order"] < items[2]["sort_order"] + + +# --- Delete --- + + +async def test_delete_item(client: AsyncClient, auth_headers: dict, test_session: dict): + """Delete removes the item.""" + create_resp = await client.post( + f"/api/v1/sessions/{test_session['id']}/supporting-data", + json={ + "label": "To delete", + "data_type": "text_snippet", + "content": "Will be removed", + }, + headers=auth_headers, + ) + assert create_resp.status_code == 201 + item_id = create_resp.json()["id"] + + delete_resp = await client.delete( + f"/api/v1/sessions/{test_session['id']}/supporting-data/{item_id}", + headers=auth_headers, + ) + assert delete_resp.status_code == 204 + + # Verify it's gone + list_resp = await client.get( + f"/api/v1/sessions/{test_session['id']}/supporting-data", + headers=auth_headers, + ) + assert list_resp.status_code == 200 + assert len(list_resp.json()) == 0 + + +# --- Validation --- + + +async def test_exceed_20_item_limit(client: AsyncClient, auth_headers: dict, test_session: dict): + """Cannot exceed 20 items per session — returns 400.""" + for i in range(20): + resp = await client.post( + f"/api/v1/sessions/{test_session['id']}/supporting-data", + json={ + "label": f"Item {i}", + "data_type": "text_snippet", + "content": f"Content {i}", + }, + headers=auth_headers, + ) + assert resp.status_code == 201, f"Failed creating item {i}: {resp.text}" + + # 21st should fail + response = await client.post( + f"/api/v1/sessions/{test_session['id']}/supporting-data", + json={ + "label": "One too many", + "data_type": "text_snippet", + "content": "Should fail", + }, + headers=auth_headers, + ) + assert response.status_code == 400 + assert "20" in response.json()["detail"] + + +async def test_screenshot_exceeds_2mb(client: AsyncClient, auth_headers: dict, test_session: dict): + """Screenshot over 2MB raw (base64 decoded) — returns 400.""" + # Create content that decodes to > 2MB + large_raw = b"\x00" * (2 * 1024 * 1024 + 1) # 2MB + 1 byte + large_b64 = base64.b64encode(large_raw).decode() + + response = await client.post( + f"/api/v1/sessions/{test_session['id']}/supporting-data", + json={ + "label": "Large screenshot", + "data_type": "screenshot", + "content": large_b64, + }, + headers=auth_headers, + ) + assert response.status_code == 400 + assert "2MB" in response.json()["detail"] + + +async def test_text_snippet_over_50k_chars(client: AsyncClient, auth_headers: dict, test_session: dict): + """Text snippet over 50,000 characters — returns 400.""" + response = await client.post( + f"/api/v1/sessions/{test_session['id']}/supporting-data", + json={ + "label": "Huge text", + "data_type": "text_snippet", + "content": "x" * 50_001, + }, + headers=auth_headers, + ) + assert response.status_code == 400 + assert "50000" in response.json()["detail"] + + +# --- Update --- + + +async def test_patch_update_label(client: AsyncClient, auth_headers: dict, test_session: dict): + """PATCH to update label returns updated item.""" + create_resp = await client.post( + f"/api/v1/sessions/{test_session['id']}/supporting-data", + json={ + "label": "Original label", + "data_type": "text_snippet", + "content": "Some content", + }, + headers=auth_headers, + ) + assert create_resp.status_code == 201 + item_id = create_resp.json()["id"] + + patch_resp = await client.patch( + f"/api/v1/sessions/{test_session['id']}/supporting-data/{item_id}", + json={"label": "Updated label"}, + headers=auth_headers, + ) + assert patch_resp.status_code == 200 + data = patch_resp.json() + assert data["label"] == "Updated label" + assert data["content"] == "Some content" # unchanged -- 2.49.1 From 312024e14331d0f990588cebc1c94bc48e7d80f1 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Tue, 17 Mar 2026 00:28:22 -0400 Subject: [PATCH 09/16] feat: add PDF export generation via WeasyPrint with branded template Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/Dockerfile | 4 + backend/app/api/endpoints/sessions.py | 16 ++ backend/app/services/export_service.py | 189 ++++++++++++- backend/app/templates/export_pdf.html | 378 +++++++++++++++++++++++++ backend/requirements.txt | 4 + backend/tests/test_pdf_export.py | 96 +++++++ 6 files changed, 686 insertions(+), 1 deletion(-) create mode 100644 backend/app/templates/export_pdf.html create mode 100644 backend/tests/test_pdf_export.py diff --git a/backend/Dockerfile b/backend/Dockerfile index 0bccd57a..73e68e91 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -6,6 +6,10 @@ WORKDIR /app RUN apt-get update && apt-get install -y \ gcc \ libpq-dev \ + libpango1.0-dev \ + libcairo2-dev \ + libgdk-pixbuf2.0-dev \ + libffi-dev \ && rm -rf /var/lib/apt/lists/* # Install Python dependencies diff --git a/backend/app/api/endpoints/sessions.py b/backend/app/api/endpoints/sessions.py index e0e303a2..47a238a5 100644 --- a/backend/app/api/endpoints/sessions.py +++ b/backend/app/api/endpoints/sessions.py @@ -391,6 +391,22 @@ 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"'}, + ) + # Generate export based on format if export_options.format == "markdown": content = generate_markdown_export(session, export_options) diff --git a/backend/app/services/export_service.py b/backend/app/services/export_service.py index 8654eb89..15e1426b 100644 --- a/backend/app/services/export_service.py +++ b/backend/app/services/export_service.py @@ -1,11 +1,13 @@ """ Session export generators for ResolutionFlow. -Provides markdown, plain text, HTML, and PSA/ticket note export formatters +Provides markdown, plain text, HTML, PDF, and PSA/ticket note export formatters for troubleshooting sessions. """ import html +import os from datetime import datetime +from pathlib import Path from typing import Any from app.models.session import Session @@ -904,3 +906,188 @@ def _generate_procedural_psa(session: Session, options: SessionExport) -> str: lines.append(f"Duration: {_format_duration(session.started_at, session.completed_at)}") return "\n".join(lines) + + +async def generate_pdf_export(session: Session, options: SessionExport, db) -> bytes: + """Generate PDF export using WeasyPrint and a Jinja2 HTML template. + + Args: + session: The session to export. + options: Export options (redaction_mode, max_step_index, etc.). + db: Async database session for loading supporting data and branding. + + Returns: + PDF file contents as bytes. + """ + from jinja2 import Environment, FileSystemLoader + import weasyprint + from sqlalchemy import select as sa_select + + # Load Jinja2 template + template_dir = Path(__file__).resolve().parent.parent / "templates" + env = Environment(loader=FileSystemLoader(str(template_dir)), autoescape=True) + template = env.get_template("export_pdf.html") + + # Tree snapshot data + tree_snapshot = session.tree_snapshot or {} + flow_title = tree_snapshot.get("name", "Session Export") + tree_type = tree_snapshot.get("tree_type", "troubleshooting") + is_procedural = tree_type == "procedural" + report_type = "Procedure Report" if is_procedural else "Troubleshooting Report" + + # Branding — check team first, then user (solo pros) + logo_data = None + logo_content_type = None + company_name = None + + from app.models.user import User + user_result = await db.execute( + sa_select(User).where(User.id == session.user_id) + ) + user = user_result.scalar_one_or_none() + engineer_name = user.name if user else "Unknown" + + if user and user.team_id: + from app.models.team import Team + team_result = await db.execute( + sa_select(Team).where(Team.id == user.team_id) + ) + team = team_result.scalar_one_or_none() + if team: + logo_data = team.logo_data + logo_content_type = team.logo_content_type + company_name = team.company_display_name or team.name + elif user: + logo_data = user.logo_data + logo_content_type = user.logo_content_type + company_name = user.company_display_name + + has_custom_logo = bool(logo_data) + + # Build steps list from decisions + decisions = session.decisions or [] + if options.max_step_index is not None: + decisions = decisions[:options.max_step_index] + + steps = [] + for decision in decisions: + title = decision.get("question") or decision.get("action_performed", "Step") + answer = decision.get("answer", "") + notes = decision.get("notes", "") + duration_seconds = _get_step_duration_seconds(decision) + duration_str = _format_step_duration(duration_seconds) if duration_seconds is not None else None + + if is_procedural: + completed = answer == "completed" + decision_text = "Completed" if completed else ("Skipped" if answer else "") + else: + decision_text = answer + + steps.append({ + "title": title, + "decision": decision_text, + "notes": notes, + "duration": duration_str, + }) + + # Query supporting data + from app.models.supporting_data import SessionSupportingData + sd_result = await db.execute( + sa_select(SessionSupportingData) + .where(SessionSupportingData.session_id == session.id) + .order_by(SessionSupportingData.sort_order) + ) + supporting_data_rows = sd_result.scalars().all() + supporting_data = [ + { + "label": sd.label, + "data_type": sd.data_type, + "content": sd.content, + "content_type": sd.content_type, + } + for sd in supporting_data_rows + ] + + # Calculate duration and format outcome + duration = _format_duration(session.started_at, session.completed_at) + session_date = session.started_at.strftime("%Y-%m-%d %H:%M") + outcome_label = _get_outcome_label(session) or ("In Progress" if not session.completed_at else "Completed") + outcome_raw = getattr(session, "outcome", None) or "" + outcome_class = f"outcome-{outcome_raw}" if outcome_raw else "" + + # Build summary text + summary_text = "" + if options.include_summary: + summary_fields = _build_summary_fields(session) + parts = [] + for label, value in summary_fields.items(): + if value: + parts.append(f"{label.replace('_', ' ').title()}: {value}") + summary_text = "\n".join(parts) + + # Resolution / outcome notes as summary fallback + if not summary_text: + _raw_notes = getattr(session, "outcome_notes", None) + if isinstance(_raw_notes, str) and _raw_notes.strip(): + summary_text = _raw_notes.strip() + + generated_at = datetime.utcnow().strftime("%Y-%m-%d %H:%M UTC") + + # Variable resolution + session_vars = getattr(session, "session_variables", None) or {} + if session_vars: + from app.services.variable_service import resolve_variables + flow_title = resolve_variables(flow_title, session_vars) + summary_text = resolve_variables(summary_text, session_vars) + for step in steps: + step["title"] = resolve_variables(step["title"], session_vars) + if step["decision"]: + step["decision"] = resolve_variables(step["decision"], session_vars) + if step["notes"]: + step["notes"] = resolve_variables(step["notes"], session_vars) + for sd in supporting_data: + if sd["data_type"] == "text_snippet": + sd["content"] = resolve_variables(sd["content"], session_vars) + + # Apply redaction + if options.redaction_mode == "mask": + from app.services.redaction_service import apply_redaction_to_text + try: + flow_title, _ = apply_redaction_to_text(flow_title) + summary_text, _ = apply_redaction_to_text(summary_text) + for step in steps: + step["title"], _ = apply_redaction_to_text(step["title"]) + if step["decision"]: + step["decision"], _ = apply_redaction_to_text(step["decision"]) + if step["notes"]: + step["notes"], _ = apply_redaction_to_text(step["notes"]) + for sd in supporting_data: + if sd["data_type"] == "text_snippet": + sd["content"], _ = apply_redaction_to_text(sd["content"]) + except Exception: + pass # Redaction is best-effort for PDF + + # Render HTML + html_content = template.render( + report_type=report_type, + flow_title=flow_title, + logo_data=logo_data, + logo_content_type=logo_content_type or "image/png", + has_custom_logo=has_custom_logo, + company_name=company_name, + engineer_name=engineer_name, + client_name=session.client_name, + ticket_number=session.ticket_number, + session_date=session_date, + duration=duration, + outcome_class=outcome_class, + outcome_display=outcome_label, + summary=summary_text, + steps=steps, + supporting_data=supporting_data, + generated_at=generated_at, + ) + + # Convert to PDF + pdf_bytes = weasyprint.HTML(string=html_content).write_pdf() + return pdf_bytes diff --git a/backend/app/templates/export_pdf.html b/backend/app/templates/export_pdf.html new file mode 100644 index 00000000..8a7b7c0a --- /dev/null +++ b/backend/app/templates/export_pdf.html @@ -0,0 +1,378 @@ + + + + + + + + + +
+
+
{{ report_type }}
+
{{ flow_title }}
+ {% if company_name %} +
{{ company_name }}
+ {% endif %} +
+ {% if logo_data %} + + {% endif %} +
+ + + + + + {% if summary %} +
+
Summary
+
{{ summary }}
+
+ {% endif %} + + + {% if steps %} +
+
Troubleshooting Path
+ {% for step in steps %} +
+
+
{{ loop.index }}. {{ step.title }}
+ {% if step.decision %} +
{{ step.decision }}
+ {% endif %} + {% if step.notes %} +
{{ step.notes }}
+ {% endif %} + {% if step.duration %} +
{{ step.duration }}
+ {% endif %} +
+
+ {% endfor %} +
+ {% endif %} + + + {% if supporting_data %} +
+
Supporting Data
+ {% for item in supporting_data %} +
+
{{ item.label }}
+
+ {% if item.data_type == "screenshot" %} + {{ item.label }} + {% else %} +
{{ item.content }}
+ {% endif %} +
+
+ {% endfor %} +
+ {% endif %} + + + diff --git a/backend/requirements.txt b/backend/requirements.txt index 189c871e..b884f6e7 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -42,6 +42,10 @@ voyageai>=0.3.0 # Monitoring sentry-sdk[fastapi]>=2.54.0 +# PDF Export +weasyprint>=62.0 +jinja2>=3.1.0 + # Utilities python-dotenv==1.0.1 croniter>=2.0.0 diff --git a/backend/tests/test_pdf_export.py b/backend/tests/test_pdf_export.py new file mode 100644 index 00000000..da2666dc --- /dev/null +++ b/backend/tests/test_pdf_export.py @@ -0,0 +1,96 @@ +"""Tests for PDF export via WeasyPrint.""" + +import pytest +from httpx import AsyncClient + + +@pytest.mark.asyncio +class TestPDFExport: + """Test PDF export endpoint.""" + + async def test_export_pdf_returns_pdf_content( + self, client: AsyncClient, auth_headers: dict, test_tree: dict + ): + """Test that PDF export returns application/pdf content starting with %PDF.""" + # Create a session + create_response = await client.post( + "/api/v1/sessions", + json={"tree_id": test_tree["id"], "ticket_number": "PDF-001"}, + headers=auth_headers, + ) + assert create_response.status_code in (200, 201) + session_id = create_response.json()["id"] + + # Add a decision so there's content + await client.put( + f"/api/v1/sessions/{session_id}", + json={ + "decisions": [ + { + "node_id": "root", + "question": "Is this a test?", + "answer": "Yes", + "notes": "PDF export test", + "timestamp": "2026-03-17T10:00:00Z", + } + ] + }, + headers=auth_headers, + ) + + # Export as PDF + response = await client.post( + f"/api/v1/sessions/{session_id}/export", + json={"format": "pdf", "include_tree_info": True}, + headers=auth_headers, + ) + + assert response.status_code == 200 + assert response.headers["content-type"] == "application/pdf" + assert "session-export-" in response.headers.get("content-disposition", "") + # PDF files start with %PDF + assert response.content[:5] == b"%PDF-" + + async def test_export_pdf_with_no_supporting_data( + self, client: AsyncClient, auth_headers: dict, test_tree: dict + ): + """Test PDF export works when session has no supporting data.""" + create_response = await client.post( + "/api/v1/sessions", + json={"tree_id": test_tree["id"]}, + headers=auth_headers, + ) + assert create_response.status_code in (200, 201) + session_id = create_response.json()["id"] + + response = await client.post( + f"/api/v1/sessions/{session_id}/export", + json={"format": "pdf"}, + headers=auth_headers, + ) + + assert response.status_code == 200 + assert response.headers["content-type"] == "application/pdf" + assert response.content[:5] == b"%PDF-" + + async def test_existing_markdown_export_still_works( + self, client: AsyncClient, auth_headers: dict, test_tree: dict + ): + """Verify markdown export is unaffected by PDF addition.""" + create_response = await client.post( + "/api/v1/sessions", + json={"tree_id": test_tree["id"], "ticket_number": "MD-001"}, + headers=auth_headers, + ) + assert create_response.status_code in (200, 201) + session_id = create_response.json()["id"] + + response = await client.post( + f"/api/v1/sessions/{session_id}/export", + json={"format": "markdown", "include_tree_info": True}, + headers=auth_headers, + ) + + assert response.status_code == 200 + assert "text/markdown" in response.headers["content-type"] + assert "MD-001" in response.text -- 2.49.1 From f7271e22ae979548e482b5f0037ad4618e40472a Mon Sep 17 00:00:00 2001 From: chihlasm Date: Tue, 17 Mar 2026 00:39:01 -0400 Subject: [PATCH 10/16] feat: include supporting data in all export formats Query supporting data in the export endpoint and pass to markdown, text, HTML, and PSA export generators. Each format renders text snippets and screenshot placeholders in its native style. Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/app/api/endpoints/sessions.py | 25 +++++++++-- backend/app/services/export_service.py | 59 ++++++++++++++++++++++++-- 2 files changed, 76 insertions(+), 8 deletions(-) diff --git a/backend/app/api/endpoints/sessions.py b/backend/app/api/endpoints/sessions.py index 47a238a5..ea4375f1 100644 --- a/backend/app/api/endpoints/sessions.py +++ b/backend/app/api/endpoints/sessions.py @@ -407,18 +407,35 @@ async def export_session( headers={"Content-Disposition": f'attachment; filename="session-export-{session_id}.pdf"'}, ) + # Query supporting data for non-PDF formats + from app.models.supporting_data import SessionSupportingData + sd_result = await db.execute( + select(SessionSupportingData) + .where(SessionSupportingData.session_id == session_id) + .order_by(SessionSupportingData.sort_order) + ) + supporting_data_items = [ + { + "label": sd.label, + "data_type": sd.data_type, + "content": sd.content, + "content_type": sd.content_type, + } + for sd in sd_result.scalars().all() + ] + # Generate export based on format if export_options.format == "markdown": - content = generate_markdown_export(session, export_options) + content = generate_markdown_export(session, export_options, supporting_data=supporting_data_items) media_type = "text/markdown" elif export_options.format == "html": - content = generate_html_export(session, export_options) + content = generate_html_export(session, export_options, supporting_data=supporting_data_items) media_type = "text/html" elif export_options.format == "psa": - content = generate_psa_export(session, export_options) + content = generate_psa_export(session, export_options, supporting_data=supporting_data_items) media_type = "text/plain" else: # text - content = generate_text_export(session, export_options) + content = generate_text_export(session, export_options, supporting_data=supporting_data_items) media_type = "text/plain" # Resolve variables in export output diff --git a/backend/app/services/export_service.py b/backend/app/services/export_service.py index 15e1426b..7d3d8091 100644 --- a/backend/app/services/export_service.py +++ b/backend/app/services/export_service.py @@ -171,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) @@ -261,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 '' @@ -286,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) @@ -361,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 '' @@ -382,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) @@ -472,6 +501,15 @@ def generate_html_export(session: Session, options: SessionExport) -> str: html_parts.append(f'

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

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

Supporting Data

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

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

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

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

{html.escape(sd[
') + # Resolution _raw_notes = getattr(session, 'outcome_notes', None) outcome_notes = _raw_notes if isinstance(_raw_notes, str) else '' @@ -490,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) @@ -561,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 ---") -- 2.49.1 From 85d1ed80283e3470b483b3640f2b0fdc76951e94 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Tue, 17 Mar 2026 00:57:27 -0400 Subject: [PATCH 11/16] feat: upgrade EmptyState component with illustration and learn more support Add illustration and learnMoreLink props to EmptyState (backward compatible). Create EmptyStateIllustrations.tsx with 7 brand-themed SVG illustrations. Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/components/common/EmptyState.tsx | 34 ++++- .../common/EmptyStateIllustrations.tsx | 130 ++++++++++++++++++ 2 files changed, 161 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/common/EmptyStateIllustrations.tsx diff --git a/frontend/src/components/common/EmptyState.tsx b/frontend/src/components/common/EmptyState.tsx index 22e4a266..b917eb6b 100644 --- a/frontend/src/components/common/EmptyState.tsx +++ b/frontend/src/components/common/EmptyState.tsx @@ -1,23 +1,51 @@ import type { ReactNode } from 'react' +import { Link } from 'react-router-dom' import { cn } from '@/lib/utils' interface EmptyStateProps { icon?: ReactNode + illustration?: ReactNode title: string description?: string action?: ReactNode + learnMoreLink?: string + learnMoreText?: string className?: string } -export function EmptyState({ icon, title, description, action, className }: EmptyStateProps) { +export function EmptyState({ + icon, + illustration, + title, + description, + action, + learnMoreLink, + learnMoreText = 'Learn more', + className, +}: EmptyStateProps) { return (
- {icon &&
{icon}
} + {illustration && ( +
+ {illustration} +
+ )} + {!illustration && icon && ( +
{icon}
+ )}

{title}

{description && ( -

{description}

+

{description}

)} {action &&
{action}
} + {learnMoreLink && ( + + {learnMoreText} → + + )}
) } diff --git a/frontend/src/components/common/EmptyStateIllustrations.tsx b/frontend/src/components/common/EmptyStateIllustrations.tsx new file mode 100644 index 00000000..5ea17830 --- /dev/null +++ b/frontend/src/components/common/EmptyStateIllustrations.tsx @@ -0,0 +1,130 @@ +/** + * SVG illustrations for EmptyState components. + * Each uses the brand cyan palette (#06b6d4 / #22d3ee) at low opacity. + * ViewBox: 80x60, simple line art style. + */ + +export function FlowIllustration() { + return ( + + {/* Root node */} + + {/* Branches */} + + + {/* Left child */} + + {/* Right child */} + + {/* Leaf branches */} + + + + + + ) +} + +export function AnalyticsIllustration() { + return ( + + {/* Bars */} + + + + + {/* Baseline */} + + + ) +} + +export function SessionIllustration() { + return ( + + {/* Card 1 */} + + + + {/* Card 2 */} + + + + {/* Card 3 */} + + + + + ) +} + +export function IntegrationIllustration() { + return ( + + {/* Left box */} + + + + {/* Right box */} + + + + {/* Dashed arrows */} + + + {/* Arrow tips */} + + + + ) +} + +export function StepLibraryIllustration() { + return ( + + {/* List items */} + + + + + + + + + + ) +} + +export function ScriptIllustration() { + return ( + + {/* Terminal window */} + + {/* Title bar */} + + + + {/* Code lines */} + + + + + + ) +} + +export function ShareIllustration() { + return ( + + {/* Center node */} + + {/* Top-right node */} + + {/* Bottom-right node */} + + {/* Connecting lines */} + + + + ) +} -- 2.49.1 From dfdc6cae9cd22bab4a450f77665d987243451d61 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Tue, 17 Mar 2026 01:21:11 -0400 Subject: [PATCH 12/16] feat: roll out illustrative empty states across 8 pages with 2 new guide entries - TreeLibraryPage: split empty state into no-flows (illustration + CTA) vs no-filter-results - MyAnalyticsPage/TeamAnalyticsPage: add zero-sessions empty state with illustration - SessionHistoryPage: split into no-sessions (illustration) vs no-filter-results - StepLibraryBrowser: illustrative empty state when no steps exist - ScriptTemplateList: replace plain empty state with ScriptIllustration - MySharesPage: replace icon-based empty state with ShareIllustration - IntegrationsPage: add IntegrationIllustration above setup form - Add script-templates and psa-setup guides to guides data - Add EmptyState vitest tests (7 tests) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../common/__tests__/EmptyState.test.tsx | 92 +++++++++++++++++++ .../components/scripts/ScriptTemplateList.tsx | 15 ++- .../step-library/StepLibraryBrowser.tsx | 26 ++++-- frontend/src/data/guides.ts | 80 ++++++++++++++++ frontend/src/pages/MyAnalyticsPage.tsx | 23 +++++ frontend/src/pages/MySharesPage.tsx | 26 +++--- frontend/src/pages/SessionHistoryPage.tsx | 36 +++++--- frontend/src/pages/TeamAnalyticsPage.tsx | 23 +++++ frontend/src/pages/TreeLibraryPage.tsx | 32 +++++-- .../src/pages/account/IntegrationsPage.tsx | 14 +++ 10 files changed, 324 insertions(+), 43 deletions(-) create mode 100644 frontend/src/components/common/__tests__/EmptyState.test.tsx diff --git a/frontend/src/components/common/__tests__/EmptyState.test.tsx b/frontend/src/components/common/__tests__/EmptyState.test.tsx new file mode 100644 index 00000000..83b81a3c --- /dev/null +++ b/frontend/src/components/common/__tests__/EmptyState.test.tsx @@ -0,0 +1,92 @@ +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import { BrowserRouter } from 'react-router-dom' +import { EmptyState } from '../EmptyState' +import { FlowIllustration } from '../EmptyStateIllustrations' + +function renderWithRouter(ui: React.ReactElement) { + return render({ui}) +} + +describe('EmptyState', () => { + it('renders title and description', () => { + renderWithRouter( + + ) + + expect(screen.getByText('No items found')).toBeInTheDocument() + expect(screen.getByText('Try adjusting your filters.')).toBeInTheDocument() + }) + + it('renders illustration when provided', () => { + const { container } = renderWithRouter( + } + /> + ) + + const svg = container.querySelector('svg') + expect(svg).toBeInTheDocument() + }) + + it('renders action button', () => { + renderWithRouter( + Create New} + /> + ) + + expect(screen.getByRole('button', { name: 'Create New' })).toBeInTheDocument() + }) + + it('renders learn more link with correct href', () => { + renderWithRouter( + + ) + + const link = screen.getByText(/Learn more/i) + expect(link).toBeInTheDocument() + expect(link).toHaveAttribute('href', '/guides/creating-flows') + }) + + it('renders custom learn more text', () => { + renderWithRouter( + + ) + + expect(screen.getByText(/View guide/i)).toBeInTheDocument() + }) + + it('renders without optional props', () => { + renderWithRouter() + + expect(screen.getByText('Just a title')).toBeInTheDocument() + expect(screen.queryByRole('button')).not.toBeInTheDocument() + expect(screen.queryByRole('link')).not.toBeInTheDocument() + }) + + it('prefers illustration over icon when both provided', () => { + const { container } = renderWithRouter( + icon} + illustration={} + /> + ) + + expect(container.querySelector('svg')).toBeInTheDocument() + expect(screen.queryByTestId('icon')).not.toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/scripts/ScriptTemplateList.tsx b/frontend/src/components/scripts/ScriptTemplateList.tsx index e0f32476..f1dc21da 100644 --- a/frontend/src/components/scripts/ScriptTemplateList.tsx +++ b/frontend/src/components/scripts/ScriptTemplateList.tsx @@ -1,5 +1,7 @@ -import { FileCode, Search } from 'lucide-react' +import { Search } from 'lucide-react' import { useScriptGeneratorStore } from '@/store/scriptGeneratorStore' +import { EmptyState } from '@/components/common/EmptyState' +import { ScriptIllustration } from '@/components/common/EmptyStateIllustrations' import { TemplateCard } from './TemplateCard' interface Props { @@ -52,10 +54,13 @@ export function ScriptTemplateList({ inputValue, onClearSearch, onConfigure }: P ) } return ( -
- -

No templates found

-
+ } + title="Automate with script templates" + description="Pre-built and custom scripts your team can reference during sessions. PowerShell, bash, and more." + learnMoreLink="/guides/script-templates" + className="px-4" + /> ) } diff --git a/frontend/src/components/step-library/StepLibraryBrowser.tsx b/frontend/src/components/step-library/StepLibraryBrowser.tsx index 3f28805e..68787f42 100644 --- a/frontend/src/components/step-library/StepLibraryBrowser.tsx +++ b/frontend/src/components/step-library/StepLibraryBrowser.tsx @@ -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 ) : steps.length === 0 ? ( -
-

No steps found

-

- {hasActiveFilters ? 'Try adjusting your filters' : 'Create your first step to get started!'} -

-
+ hasActiveFilters ? ( +
+

No steps found

+

Try adjusting your filters

+
+ ) : ( + } + 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 ? ( + + ) : undefined + } + learnMoreLink="/guides/step-library" + /> + ) ) : (
{/* My Steps */} diff --git a/frontend/src/data/guides.ts b/frontend/src/data/guides.ts index 68f596b1..413c8192 100644 --- a/frontend/src/data/guides.ts +++ b/frontend/src/data/guides.ts @@ -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.' }, + ], + }, + ], + }, ] diff --git a/frontend/src/pages/MyAnalyticsPage.tsx b/frontend/src/pages/MyAnalyticsPage.tsx index 28460d55..dfa92ac3 100644 --- a/frontend/src/pages/MyAnalyticsPage.tsx +++ b/frontend/src/pages/MyAnalyticsPage.tsx @@ -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 (
} 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 ( +
+ } + title="Track your troubleshooting performance" + description="Analytics show resolution times, common paths, and team efficiency. Data appears automatically as you complete sessions." + action={ + + Run Your First Session + + } + learnMoreLink="/guides/analytics" + /> +
+ ) + } + const { summary, time_series, top_flows } = data const outcomeBreakdown = summary.outcome_breakdown diff --git a/frontend/src/pages/MySharesPage.tsx b/frontend/src/pages/MySharesPage.tsx index 373ae67d..6feb4fdd 100644 --- a/frontend/src/pages/MySharesPage.tsx +++ b/frontend/src/pages/MySharesPage.tsx @@ -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 ? ( -
- } - title="No shared sessions" - description="Share a session from the session detail page to create a link" - action={ - - } - /> -
+ } + title="Share session results with your team" + description="Create shareable links to completed sessions for knowledge sharing and client communication." + action={ + + } + learnMoreLink="/guides/sharing-exports" + /> ) : (
{shares.map((share) => { diff --git a/frontend/src/pages/SessionHistoryPage.tsx b/frontend/src/pages/SessionHistoryPage.tsx index 49f89560..9a85fb8a 100644 --- a/frontend/src/pages/SessionHistoryPage.tsx +++ b/frontend/src/pages/SessionHistoryPage.tsx @@ -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() {
) : sessions.length === 0 ? ( - Clear all filters - ) : undefined - } - /> + } + /> + ) : ( + } + title="Your session history will appear here" + description="Every troubleshooting session is recorded with decisions, timing, and outcomes — ready for export or review." + action={ + + Start a Session + + } + learnMoreLink="/guides/sessions" + /> + ) ) : ( <>
diff --git a/frontend/src/pages/TeamAnalyticsPage.tsx b/frontend/src/pages/TeamAnalyticsPage.tsx index 48fa4c32..41fc67e6 100644 --- a/frontend/src/pages/TeamAnalyticsPage.tsx +++ b/frontend/src/pages/TeamAnalyticsPage.tsx @@ -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 (
} 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 ( +
+ } + title="Track your troubleshooting performance" + description="Analytics show resolution times, common paths, and team efficiency. Data appears automatically as you complete sessions." + action={ + + Run Your First Session + + } + learnMoreLink="/guides/analytics" + /> +
+ ) + } + const { summary, time_series, top_flows, top_engineers } = data return ( diff --git a/frontend/src/pages/TreeLibraryPage.tsx b/frontend/src/pages/TreeLibraryPage.tsx index cb2c5aec..9bafdc97 100644 --- a/frontend/src/pages/TreeLibraryPage.tsx +++ b/frontend/src/pages/TreeLibraryPage.tsx @@ -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() {
) : trees.length === 0 ? ( - + (searchQuery || hasActiveFilters) ? ( + + Clear Filters + + } + /> + ) : ( + } + title="Build your first troubleshooting flow" + description="Flows guide your team through proven resolution paths, capturing every decision along the way." + action={ + canCreateTrees ? ( + + ) : undefined + } + learnMoreLink="/guides/creating-flows" + /> + ) ) : ( <> {treeLibraryView === 'grid' && ( diff --git a/frontend/src/pages/account/IntegrationsPage.tsx b/frontend/src/pages/account/IntegrationsPage.tsx index 7c8d59f1..d0656079 100644 --- a/frontend/src/pages/account/IntegrationsPage.tsx +++ b/frontend/src/pages/account/IntegrationsPage.tsx @@ -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' && (
+ {/* Illustrative empty state when no connection exists */} + {mode === 'setup' && ( +
+ } + 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" + /> +
+ )} + {/* Setup / Edit Form */} {(mode === 'setup' || mode === 'edit') && (
-- 2.49.1 From b72eb56b7fff93f3789282338688e971be50c776 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Tue, 17 Mar 2026 01:40:32 -0400 Subject: [PATCH 13/16] feat: add onboarding checklist widget to dashboard Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/api/onboarding.ts | 21 +++ .../dashboard/OnboardingChecklist.tsx | 160 ++++++++++++++++++ frontend/src/pages/QuickStartPage.tsx | 4 + 3 files changed, 185 insertions(+) create mode 100644 frontend/src/api/onboarding.ts create mode 100644 frontend/src/components/dashboard/OnboardingChecklist.tsx diff --git a/frontend/src/api/onboarding.ts b/frontend/src/api/onboarding.ts new file mode 100644 index 00000000..4f54e687 --- /dev/null +++ b/frontend/src/api/onboarding.ts @@ -0,0 +1,21 @@ +import { apiClient } from './client' + +export interface OnboardingStatus { + created_flow: boolean + ran_session: boolean + exported_session: boolean + tried_ai_assistant: boolean + invited_teammate: boolean + connected_psa: boolean + is_team_user: boolean + dismissed: boolean +} + +export async function getOnboardingStatus(): Promise { + const response = await apiClient.get('/users/onboarding-status') + return response.data +} + +export async function dismissOnboarding(): Promise { + await apiClient.post('/users/onboarding-status/dismiss') +} diff --git a/frontend/src/components/dashboard/OnboardingChecklist.tsx b/frontend/src/components/dashboard/OnboardingChecklist.tsx new file mode 100644 index 00000000..f4552204 --- /dev/null +++ b/frontend/src/components/dashboard/OnboardingChecklist.tsx @@ -0,0 +1,160 @@ +import { useState, useEffect } from 'react' +import { useNavigate } from 'react-router-dom' +import { Check, X, ChevronRight } from 'lucide-react' +import { cn } from '@/lib/utils' +import { getOnboardingStatus, dismissOnboarding } from '@/api/onboarding' +import type { OnboardingStatus } from '@/api/onboarding' + +interface ChecklistItem { + key: keyof OnboardingStatus + label: string + path: string +} + +const SOLO_ITEMS: ChecklistItem[] = [ + { key: 'created_flow', label: 'Create your first flow', path: '/trees' }, + { key: 'ran_session', label: 'Run your first session', path: '/trees' }, + { key: 'exported_session', label: 'Export a session', path: '/sessions' }, + { key: 'tried_ai_assistant', label: 'Try the AI assistant', path: '/assistant' }, +] + +const TEAM_ITEMS: ChecklistItem[] = [ + { key: 'created_flow', label: 'Create your first flow', path: '/trees' }, + { key: 'invited_teammate', label: 'Invite a team member', path: '/account' }, + { key: 'ran_session', label: 'Run your first session', path: '/trees' }, + { key: 'connected_psa', label: 'Connect a PSA integration', path: '/account/integrations' }, + { key: 'exported_session', label: 'Export a session', path: '/sessions' }, +] + +export function OnboardingChecklist() { + const navigate = useNavigate() + const [status, setStatus] = useState(null) + const [dismissed, setDismissed] = useState(false) + const [allComplete, setAllComplete] = useState(false) + + useEffect(() => { + getOnboardingStatus() + .then(setStatus) + .catch(() => { + // Silently fail — don't show checklist if endpoint unavailable + }) + }, []) + + const items = status?.is_team_user ? TEAM_ITEMS : SOLO_ITEMS + const completedCount = status + ? items.filter((item) => status[item.key]).length + : 0 + const totalCount = items.length + const isAllDone = completedCount === totalCount && status !== null + + useEffect(() => { + if (isAllDone) { + const timer = setTimeout(() => setAllComplete(true), 2000) + return () => clearTimeout(timer) + } + }, [isAllDone]) + + // Don't render if dismissed, fully complete, or not loaded yet + if (!status || status.dismissed || dismissed || allComplete) return null + + const progressPercent = totalCount > 0 ? (completedCount / totalCount) * 100 : 0 + + const handleDismiss = async () => { + setDismissed(true) + try { + await dismissOnboarding() + } catch { + // Already hidden locally + } + } + + return ( +
+ {/* Progress bar */} +
+
+
+ +
+ {/* Header */} +
+
+

+ Getting Started +

+

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

+
+ +
+ + {/* Checklist items */} +
    + {items.map((item) => { + const done = status[item.key] + return ( +
  • + +
  • + ) + })} +
+
+
+ ) +} diff --git a/frontend/src/pages/QuickStartPage.tsx b/frontend/src/pages/QuickStartPage.tsx index ecb71bc8..26278446 100644 --- a/frontend/src/pages/QuickStartPage.tsx +++ b/frontend/src/pages/QuickStartPage.tsx @@ -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() {

+ {/* Onboarding Checklist */} + + {/* Row 1: Calendar + Quick Actions */}
-- 2.49.1 From 2339787499514b329bc0096b3a00d3ef931915b3 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Tue, 17 Mar 2026 02:03:40 -0400 Subject: [PATCH 14/16] feat: add supporting data capture, PDF export, and branding settings UI - API clients for supporting data CRUD and team branding - AddSupportingDataModal with text snippet and screenshot tabs (paste + upload) - SupportingDataPanel collapsible section integrated into both session runners - ExportPreviewModal updated with PDF format and server-side download flow - BrandingSettings component for company name and logo management - Expose team_id in UserResponse schema for branding endpoint access Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/app/schemas/user.py | 1 + frontend/src/api/branding.ts | 23 ++ frontend/src/api/supportingData.ts | 39 +++ .../session/AddSupportingDataModal.tsx | 263 ++++++++++++++++++ .../components/session/ExportPreviewModal.tsx | 131 +++++---- .../session/SupportingDataPanel.tsx | 152 ++++++++++ .../components/settings/BrandingSettings.tsx | 245 ++++++++++++++++ frontend/src/pages/AccountSettingsPage.tsx | 7 + .../src/pages/ProceduralNavigationPage.tsx | 8 + frontend/src/pages/SessionDetailPage.tsx | 31 ++- frontend/src/pages/TreeNavigationPage.tsx | 8 + frontend/src/types/session.ts | 2 +- frontend/src/types/user.ts | 1 + 13 files changed, 857 insertions(+), 54 deletions(-) create mode 100644 frontend/src/api/branding.ts create mode 100644 frontend/src/api/supportingData.ts create mode 100644 frontend/src/components/session/AddSupportingDataModal.tsx create mode 100644 frontend/src/components/session/SupportingDataPanel.tsx create mode 100644 frontend/src/components/settings/BrandingSettings.tsx diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index 496c46e9..a48df65b 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -46,6 +46,7 @@ class UserResponse(UserBase): role: str = "engineer" account_id: Optional[UUID] = None account_role: Optional[str] = None + team_id: Optional[UUID] = None is_super_admin: bool = False is_active: bool = True must_change_password: bool = False diff --git a/frontend/src/api/branding.ts b/frontend/src/api/branding.ts new file mode 100644 index 00000000..37387efb --- /dev/null +++ b/frontend/src/api/branding.ts @@ -0,0 +1,23 @@ +import { apiClient } from './client' + +export interface BrandingInfo { + company_display_name: string | null + logo_content_type: string | null + has_logo: boolean +} + +export async function getBranding(teamId: string): Promise { + const response = await apiClient.get(`/teams/${teamId}/branding`) + return response.data +} + +export async function updateBranding(teamId: string, formData: FormData): Promise { + const response = await apiClient.patch(`/teams/${teamId}/branding`, formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }) + return response.data +} + +export async function deleteLogo(teamId: string): Promise { + await apiClient.delete(`/teams/${teamId}/branding/logo`) +} diff --git a/frontend/src/api/supportingData.ts b/frontend/src/api/supportingData.ts new file mode 100644 index 00000000..0dc36546 --- /dev/null +++ b/frontend/src/api/supportingData.ts @@ -0,0 +1,39 @@ +import { apiClient } from './client' + +export interface SupportingDataItem { + id: string + session_id: string + label: string + data_type: 'text_snippet' | 'screenshot' + content: string + content_type: string | null + sort_order: number + created_at: string + updated_at: string +} + +export async function getSupportingData(sessionId: string): Promise { + const response = await apiClient.get(`/sessions/${sessionId}/supporting-data`) + return response.data +} + +export async function createSupportingData( + sessionId: string, + data: { label: string; data_type: string; content: string; content_type?: string } +): Promise { + const response = await apiClient.post(`/sessions/${sessionId}/supporting-data`, data) + return response.data +} + +export async function updateSupportingData( + sessionId: string, + itemId: string, + data: { label?: string; content?: string } +): Promise { + const response = await apiClient.patch(`/sessions/${sessionId}/supporting-data/${itemId}`, data) + return response.data +} + +export async function deleteSupportingData(sessionId: string, itemId: string): Promise { + await apiClient.delete(`/sessions/${sessionId}/supporting-data/${itemId}`) +} diff --git a/frontend/src/components/session/AddSupportingDataModal.tsx b/frontend/src/components/session/AddSupportingDataModal.tsx new file mode 100644 index 00000000..23a85883 --- /dev/null +++ b/frontend/src/components/session/AddSupportingDataModal.tsx @@ -0,0 +1,263 @@ +import { useState, useRef, useCallback } from 'react' +import { Code2, ImageIcon, Upload } from 'lucide-react' +import { Modal } from '@/components/common/Modal' +import { Button } from '@/components/ui/Button' +import { cn } from '@/lib/utils' +import { createSupportingData } from '@/api/supportingData' +import { toast } from '@/lib/toast' + +interface AddSupportingDataModalProps { + isOpen: boolean + onClose: () => void + sessionId: string + onAdded: () => void +} + +type TabType = 'text_snippet' | 'screenshot' + +const MAX_FILE_SIZE = 2 * 1024 * 1024 // 2MB + +export function AddSupportingDataModal({ isOpen, onClose, sessionId, onAdded }: AddSupportingDataModalProps) { + const [activeTab, setActiveTab] = useState('text_snippet') + const [label, setLabel] = useState('') + const [textContent, setTextContent] = useState('') + const [imageBase64, setImageBase64] = useState(null) + const [imageContentType, setImageContentType] = useState(null) + const [imageFileName, setImageFileName] = useState(null) + const [isSubmitting, setIsSubmitting] = useState(false) + const [error, setError] = useState(null) + const fileInputRef = useRef(null) + + const resetForm = () => { + setLabel('') + setTextContent('') + setImageBase64(null) + setImageContentType(null) + setImageFileName(null) + setError(null) + setActiveTab('text_snippet') + } + + const handleClose = () => { + resetForm() + onClose() + } + + const processFile = useCallback((file: File) => { + if (file.size > MAX_FILE_SIZE) { + setError('File must be under 2MB') + return + } + if (!['image/png', 'image/jpeg', 'image/svg+xml'].includes(file.type)) { + setError('Only PNG, JPEG, and SVG files are supported') + return + } + setError(null) + setImageFileName(file.name) + setImageContentType(file.type) + + const reader = new FileReader() + reader.onload = () => { + const result = reader.result as string + // Strip the data:... prefix to get raw base64 + const base64 = result.includes(',') ? result.split(',')[1] : result + setImageBase64(base64) + } + reader.readAsDataURL(file) + }, []) + + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (file) processFile(file) + } + + const handlePaste = useCallback((e: React.ClipboardEvent) => { + const items = e.clipboardData?.items + if (!items) return + for (const item of items) { + if (item.type.startsWith('image/')) { + e.preventDefault() + const file = item.getAsFile() + if (file) processFile(file) + return + } + } + }, [processFile]) + + const handleSubmit = async () => { + if (!label.trim()) { + setError('Label is required') + return + } + + if (activeTab === 'text_snippet' && !textContent.trim()) { + setError('Content is required') + return + } + + if (activeTab === 'screenshot' && !imageBase64) { + setError('Please select or paste an image') + return + } + + setIsSubmitting(true) + setError(null) + try { + await createSupportingData(sessionId, { + label: label.trim(), + data_type: activeTab, + content: activeTab === 'text_snippet' ? textContent : imageBase64!, + content_type: activeTab === 'screenshot' ? (imageContentType ?? undefined) : undefined, + }) + toast.success('Supporting data added') + onAdded() + handleClose() + } catch (err) { + console.error('Failed to add supporting data:', err) + setError('Failed to save. Please try again.') + } finally { + setIsSubmitting(false) + } + } + + return ( + + {/* Tabs */} +
+ + +
+ + {/* Label */} +
+ + setLabel(e.target.value)} + placeholder={activeTab === 'text_snippet' ? 'e.g. Error log output' : 'e.g. Blue screen photo'} + className={cn( + 'w-full rounded-md border border-border bg-card px-3 py-2 text-sm', + 'text-foreground placeholder:text-muted-foreground', + 'focus:border-[rgba(6,182,212,0.3)] focus:outline-hidden focus:ring-1 focus:ring-primary/20' + )} + /> +
+ + {/* Text Snippet Tab Content */} + {activeTab === 'text_snippet' && ( +
+ +