chore: clean up old plan docs and add analytics improvement notes

Remove 15 completed/superseded plan documents. Add analytics
improvements reference and visual QA design migration notes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
chihlasm
2026-02-16 15:27:42 -05:00
parent 4a8d45f1f1
commit 697669ffe4
17 changed files with 738 additions and 5062 deletions

View File

@@ -1,520 +0,0 @@
# Feature Design: Draft Trees & Custom Steps
> **Date:** February 3, 2026
> **Status:** Planned for Phase 3
> **Related Issues:** TBD
> **Dependencies:** Tree Editor Validation UI (Issue #1)
---
## Overview
Enable users to save incomplete trees and custom steps as drafts, allowing them to return later to finish editing without validation errors blocking their work.
**Use Cases:**
- Building a complex tree over multiple sessions
- Starting a tree without all solution nodes defined
- Experimenting with tree structures before publishing
- Saving custom steps for later refinement
---
## Motivation
Currently, validation errors block saving trees. This creates friction when:
- User wants to save progress on a complex tree (10+ nodes)
- User is interrupted mid-editing and needs to save incomplete work
- User wants to experiment without committing to a "valid" structure
- User creates a custom step during troubleshooting but wants to refine it later
**Goal:** Allow users to save work-in-progress without bypassing quality checks for published trees.
---
## Design
### Database Changes
#### Trees Table
Add `status` column to `trees` table:
```sql
ALTER TABLE trees
ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'published';
ALTER TABLE trees
ADD CONSTRAINT trees_status_check
CHECK (status IN ('draft', 'published'));
CREATE INDEX idx_trees_status ON trees(status);
```
**Statuses:**
- `draft` - Incomplete, may have validation errors, only visible to author
- `published` - Complete, passes validation, visible per sharing settings
#### Step Library Table
Add `status` column to `step_library` table:
```sql
ALTER TABLE step_library
ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'published';
ALTER TABLE step_library
ADD CONSTRAINT step_library_status_check
CHECK (status IN ('draft', 'published'));
CREATE INDEX idx_step_library_status ON step_library(status);
```
---
## API Changes
### Trees Endpoints
#### GET /api/v1/trees
Add query parameter:
```python
@router.get("/")
async def list_trees(
include_drafts: bool = False, # NEW
category_id: Optional[UUID] = None,
tags: Optional[str] = None,
# ... existing params
):
"""
List trees.
By default, only returns published trees.
Set include_drafts=true to include user's own draft trees.
"""
```
**Logic:**
- Default: Only return `status='published'` trees
- `include_drafts=true`: Return published trees + current user's drafts
- Never show other users' drafts
#### POST /api/v1/trees
```python
class TreeCreate(BaseModel):
name: str
description: Optional[str] = None
tree_structure: dict
status: str = "published" # NEW: default to published
# ... existing fields
```
**Validation:**
- `status='draft'`: Skip validation, allow saving with errors
- `status='published'`: Run full validation, reject if errors exist
#### PUT /api/v1/trees/{id}
```python
class TreeUpdate(BaseModel):
name: Optional[str] = None
tree_structure: Optional[dict] = None
status: Optional[str] = None # NEW: allow status change
# ... existing fields
```
**Validation:**
- Changing `draft``published`: Run validation, reject if errors
- Changing `published``draft`: Allow without validation
- Updating draft: Skip validation
- Updating published: Run validation
#### GET /api/v1/trees/{id}/can-publish
```python
@router.get("/{id}/can-publish")
async def can_publish_tree(id: UUID) -> dict:
"""
Check if a draft tree can be published.
Returns:
{
"can_publish": bool,
"errors": ValidationError[],
"warnings": ValidationError[]
}
"""
```
**Use case:** Frontend calls this before showing "Publish" button to preview errors.
### Step Library Endpoints
Same pattern as trees:
- `GET /api/v1/steps?include_drafts=true`
- `POST /api/v1/steps` with `status` field
- `PUT /api/v1/steps/{id}` with status change validation
- `GET /api/v1/steps/{id}/can-publish`
---
## Frontend Changes
### Tree Library Page
**Visual Changes:**
```tsx
// Draft badge on tree cards
{tree.status === 'draft' && (
<span className="rounded-full bg-yellow-100 px-2 py-1 text-xs font-medium text-yellow-800 dark:bg-yellow-900 dark:text-yellow-100">
Draft
</span>
)}
// Filter toggle
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={includeDrafts}
onChange={(e) => setIncludeDrafts(e.target.checked)}
/>
Show my drafts
</label>
```
**Default:** Only show published trees
**With "Show my drafts" enabled:** Show published + user's drafts
### Tree Editor Page
**Save Button Logic:**
```tsx
const { canSave, validationErrors, validationWarnings } = useValidation()
const isDraft = tree.status === 'draft'
// Two-button layout when draft has errors
{isDraft && validationErrors.length > 0 ? (
<>
<button onClick={handleSaveDraft}>
Save Draft
</button>
<button
onClick={handlePublish}
disabled={validationErrors.length > 0}
title={validationErrors.length > 0 ? "Fix errors to publish" : ""}
>
Publish
</button>
</>
) : (
<button onClick={handleSave}>
{isDraft ? 'Save Draft' : 'Save'}
</button>
)}
```
**Validation Display:**
```tsx
// Show validation summary for drafts
{isDraft && (
<ValidationSummary
errors={validationErrors}
warnings={validationWarnings}
mode="draft" // Shows "Fix these to publish" message
/>
)}
// Show validation summary for published (blocks save)
{!isDraft && validationErrors.length > 0 && (
<ValidationSummary
errors={validationErrors}
warnings={validationWarnings}
mode="published" // Shows "Cannot save" message
/>
)}
```
**Status Badge in Editor:**
```tsx
<div className="flex items-center gap-2">
<h1>{tree.name}</h1>
{tree.status === 'draft' && (
<span className="rounded bg-yellow-100 px-2 py-1 text-sm font-medium text-yellow-800">
Draft
</span>
)}
</div>
```
### Tree Navigation Page
**Draft trees behavior:**
- Can be selected and used for navigation
- Show warning banner: "⚠️ This is a draft tree and may be incomplete"
- Allow session creation (useful for testing draft trees)
### Step Library Browser
**Draft custom steps:**
```tsx
// In CustomStepModal, add checkbox:
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={saveAsDraft}
onChange={(e) => setSaveAsDraft(e.target.checked)}
/>
Save as draft (you can refine it later)
</label>
// In StepLibraryBrowser, filter control:
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={showDrafts}
onChange={(e) => setShowDrafts(e.target.checked)}
/>
Show my draft steps
</label>
```
---
## User Flows
### Flow 1: Save Draft Tree
1. User creates new tree, clicks "Create Tree"
2. Tree Editor opens, user adds nodes
3. User clicks "Save Draft" (or just "Save" if creating as draft from start)
4. Validation runs but doesn't block—tree saved with `status='draft'`
5. Success message: "Draft saved. Publish when ready."
### Flow 2: Publish Draft Tree
1. User opens draft tree in editor
2. ValidationSummary shows errors/warnings
3. User fixes all errors
4. "Publish" button becomes enabled
5. User clicks "Publish"
6. Tree status changes to `published`
7. Success message: "Tree published and available to team"
### Flow 3: Unpublish Tree
1. User opens published tree
2. Clicks "Convert to Draft" (in dropdown menu)
3. Confirmation modal: "This will hide the tree from others. Continue?"
4. Tree status changes to `draft`
5. Tree removed from other users' tree library view
### Flow 4: Save Draft Custom Step
1. User adds custom step during navigation
2. In CustomStepModal, checks "Save as draft"
3. Step saved to personal library with `status='draft'`
4. Step inserted into current session (works like published step)
5. Later, user opens "My Steps" page, refines draft, publishes
---
## Validation Rules
### Draft Trees
- ✅ Can save with missing required fields
- ✅ Can save with orphan nodes
- ✅ Can save with circular references
- ✅ Can save without solution nodes
- ❌ Still validate JSONB structure (prevent corrupted data)
### Published Trees
- ❌ Cannot save with any validation errors
- ⚠️ Can save with warnings (orphan nodes, etc.)
- ✅ Must have at least one solution node
- ✅ Must have valid tree_structure
### Publishing Transition
- When `draft``published`: Run full validation, reject if errors
- Show clear error message: "Cannot publish: 3 errors found. [View Details]"
---
## UI Mockup Descriptions
### Tree Library Page
```
┌─────────────────────────────────────────────────┐
│ Tree Library │
│ │
│ [Search...] [Category ▼] [+ New Tree] │
│ │
│ ☑ Show my drafts │
│ │
│ ┌─────────────────────────────────┐ │
│ │ Citrix Connection Issues [DRAFT]│ │
│ │ Last edited: 2 hours ago │ │
│ │ 5 nodes · 2 errors │ │
│ │ [Continue Editing] │ │
│ └─────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────┐ │
│ │ Outlook Won't Start │ │
│ │ Last used: Yesterday │ │
│ │ 12 nodes · Published │ │
│ │ [Start Session] │ │
│ └─────────────────────────────────┘ │
└─────────────────────────────────────────────────┘
```
### Tree Editor - Draft Mode
```
┌─────────────────────────────────────────────────┐
│ Citrix Connection Issues [DRAFT] │
│ │
│ ⚠ Validation (2 errors, 1 warning) │
│ ├─ ❌ Tree must have at least one solution node│
│ ├─ ❌ Node "Check firewall" is orphaned │
│ └─ ⚠ Node "Reboot" has no help text │
│ │
│ [Node editing area...] │
│ │
│ [Cancel] [Save Draft] [Publish] ← disabled │
└─────────────────────────────────────────────────┘
```
### Tree Editor - Ready to Publish
```
┌─────────────────────────────────────────────────┐
│ Citrix Connection Issues [DRAFT] │
│ │
│ ✅ No validation errors │
│ ⚠ 1 warning: Node "Reboot" has no help text │
│ │
│ [Node editing area...] │
│ │
│ [Cancel] [Save Draft] [Publish] ← enabled │
└─────────────────────────────────────────────────┘
```
---
## Implementation Phases
### Phase 1: Backend Foundation
- [ ] Add `status` column to `trees` table
- [ ] Update Trees API endpoints (list, create, update)
- [ ] Add `can_publish` endpoint
- [ ] Update validation logic to respect status
- [ ] Write tests for draft/publish transitions
### Phase 2: Frontend - Trees
- [ ] Update Tree Library to filter by status
- [ ] Add "Show my drafts" toggle
- [ ] Update Tree Editor save button logic
- [ ] Add "Publish" button for drafts
- [ ] Add status badge to tree cards and editor
- [ ] Add confirmation modal for unpublishing
### Phase 3: Backend - Step Library
- [ ] Add `status` column to `step_library` table
- [ ] Update Step Library API endpoints
- [ ] Add `can_publish` endpoint for steps
- [ ] Write tests
### Phase 4: Frontend - Step Library
- [ ] Update CustomStepModal with draft option
- [ ] Update StepLibraryBrowser to filter drafts
- [ ] Add "Publish" action to step detail modal
- [ ] Add status badge to step cards
---
## Testing Checklist
### Trees
- [ ] Create draft tree with validation errors → saves successfully
- [ ] Try to publish draft with errors → rejected with clear message
- [ ] Fix errors, publish draft → becomes published
- [ ] Edit published tree, introduce error → cannot save
- [ ] Convert published tree to draft → hidden from others
- [ ] Other users cannot see my draft trees
- [ ] Draft trees show in "My Trees" when filter enabled
### Step Library
- [ ] Save custom step as draft → appears in "My Steps" with badge
- [ ] Draft steps not shown in team/public views
- [ ] Publish draft step → validation runs
- [ ] Draft step can be inserted into session (works like published)
- [ ] Edit draft step, publish when ready
### Edge Cases
- [ ] Create draft → close browser → reopen → draft still there
- [ ] Two users editing same tree: User A drafts, User B can't see draft
- [ ] Published tree with 100 uses → convert to draft → sessions still work
- [ ] Delete draft tree → no orphaned sessions
---
## Open Questions
1. **Auto-save for drafts?**
- Should drafts auto-save every N seconds like Google Docs?
- Recommendation: Phase 5 enhancement, manual save for now
2. **Draft expiration?**
- Should drafts older than 30 days be auto-deleted?
- Recommendation: No expiration for now, add later if storage becomes issue
3. **Version history for drafts?**
- Should we track versions of draft edits?
- Recommendation: Out of scope, add with general version control feature later
4. **Team drafts?**
- Should teams be able to collaborate on draft trees?
- Recommendation: Phase 6 - "shared drafts" with permissions
---
## Migration Plan
### Database Migration
```python
# Migration: add_tree_status_column
def upgrade():
# Add column with default 'published' for existing trees
op.add_column('trees', sa.Column('status', sa.String(20), nullable=False, server_default='published'))
op.create_check_constraint('trees_status_check', 'trees', "status IN ('draft', 'published')")
op.create_index('idx_trees_status', 'trees', ['status'])
# Same for step_library
op.add_column('step_library', sa.Column('status', sa.String(20), nullable=False, server_default='published'))
op.create_check_constraint('step_library_status_check', 'step_library', "status IN ('draft', 'published')")
op.create_index('idx_step_library_status', 'step_library', ['status'])
```
**Rollback safety:** All existing trees default to `published`, no data loss.
---
## Success Metrics
- **Adoption:** % of users who create at least one draft tree per month
- **Completion:** % of drafts that get published (vs abandoned)
- **Time savings:** Avg time to create complex trees (before/after draft feature)
- **Error reduction:** % reduction in "cannot save" frustration incidents
**Target:** 60% of users with 5+ trees use draft feature within 2 months of launch.
---
## Related Features
- **Tree Editor Validation** (Issue #1) - Prerequisite
- **Step Library Browser** (Issue #10) - Will benefit from draft steps
- **Tree Forking** (Issue #13) - Forked trees could start as drafts
- **Tree Sharing** (Issue #16) - Published status required to share
---
## Notes
- Draft feature inspired by Gmail drafts, Google Docs, Notion page publishing
- Key principle: **Never lose work** - always allow saving, validate on publish
- This feature enables iterative tree building, which is critical for complex MSP workflows

View File

@@ -1,489 +0,0 @@
# ResolutionFlow Feature Ideas Brainstorm
> **Date:** February 4, 2026
> **Participants:** Michael Chihlas, Claude
> **Context:** Brainstorming features tailored to MSP engineers, focused on the "document as you go" gap
---
## Design Principle
Every feature should follow the core principle that makes ResolutionFlow work:
**Engineers don't document — they troubleshoot, and the tool captures documentation as a byproduct.**
New features should:
- Reduce engineer cognitive load
- Automate capture of what they're already doing
- Make reusing knowledge frictionless
- Reduce context switching during troubleshooting
---
## Ideas Summary
| # | Feature | Category | Effort | Priority Signal |
|---|---------|----------|--------|-----------------|
| 1 | Session Time Tracking | Export enhancement | Small | High — quick win |
| 2 | Share Progress / Escalation | Collaboration | Medium | High — daily use |
| 3 | Command Output Capture | Context capture | Small | High — quick win |
| 4 | Push Steps to Active Sessions | Collaboration | Medium-Large | Medium — needs notification system |
| 5 | Path Analytics | Intelligence | Medium | Medium — needs session volume |
| 6 | Session Scratchpad | Context capture | Small-Medium | High — must-have (per Michael) |
| 7 | Multi-Tree Sessions | Session enhancement | Large | Medium — complex UX |
| 8 | Recurring Issue Detection | Intelligence | Small-Medium | High — leverages existing data |
| 9 | Tree Health Scores | Intelligence | Medium | Medium — needs session volume |
| 10 | AI Tree Intelligence | Intelligence | Large | Long-term — ultimate vision |
---
## Idea 1: Session Time Tracking
**Category:** Export enhancement
**Effort:** Small (backend export change, no new UI)
### What
Every session automatically tracks duration (start → end) and includes it in the export.
### Export Output
```
Session Duration: 23 minutes
Started: 2:30 PM | Completed: 2:53 PM
```
### Why
MSP engineers bill by the hour. They troubleshoot in ResolutionFlow, export notes, then separately log time in their PSA. This eliminates the second step.
### Implementation Notes
- Timestamps already exist in session decisions — just compute elapsed time
- Add duration to export templates (markdown, text, HTML)
- No new UI required — purely a backend export enhancement
### Future Enhancement
- When PSA integration exists: if no PSA connected, ResolutionFlow tracks time natively. If PSA connected, still track time but also push it directly to the ticket in the PSA.
- Analytics: average resolution time per tree, per client, per engineer
---
## Idea 2: Share Progress / Escalation
**Category:** Collaboration
**Effort:** Medium
### What
Two mechanisms for sharing in-progress troubleshooting context:
1. **"Share Progress" button** (available mid-session) — generates a formatted summary of steps completed so far. Copy to clipboard, paste into Teams/Slack. One click instead of typing "here's what I've tried."
2. **Read-only session link** — shareable URL where anyone with the link can see the session state. If they sign in and get assigned, they can resume from where the previous engineer left off.
### Workflow
1. Junior engineer gets stuck → clicks "Share Progress"
2. Pastes formatted summary into Teams chat with senior
3. Senior reads structured summary (not a wall of chat text)
4. If needed, senior opens the read-only link to see full detail
5. If escalating: ticket reassigned in PSA, senior resumes the session in ResolutionFlow
### Implementation Notes
- Copy/paste version is nearly free — existing export logic on incomplete sessions + "Steps remaining" section
- Read-only link: generate share token, create public read-only session view (no auth)
- Resume capability: allow session reassignment to another user
### Why This Matters
Eliminates the "what have you tried so far?" back-and-forth that happens on every escalation. The structured format means the senior gets context in 30 seconds instead of 10 minutes of chat.
---
## Idea 3: Command Output Capture
**Category:** Context capture
**Effort:** Small
### What
Action nodes (which show commands to run) get an optional "Paste Output" text area. Engineer runs the command, copies output, pastes it in.
### Export Output
```
> Ran: Get-Service -Name Spooler
> Output:
> Status: Stopped
> Name: Spooler
> Decision: Service was stopped, proceeded to restart
```
### Why
Engineers already run commands and read output. Today the output is lost — the export says "ran this command" but not what it returned. This captures the evidence.
### Implementation Notes
- Add optional `command_output` field to session decision JSONB
- Add collapsible text area below commands on action nodes in TreeNavigationPage
- Include in export with code formatting
- Pairs well with Scratchpad (Idea 6) and Share Progress (Idea 2)
### Future Enhancement
- Syntax highlighting for common output formats (PowerShell, JSON)
- Image paste for screenshots of GUI-based evidence
---
## Idea 4: Push Steps to Active Sessions
**Category:** Collaboration
**Effort:** Medium-Large
### What
A senior engineer (or anyone) can send a troubleshooting step directly to someone's active session. Flips the step library from a pull model (browse and find) to a push model (someone sends it to you).
### Workflow
1. Junior shares progress link (Idea 2)
2. Senior sees they're stuck at "VDA not registering"
3. Senior picks a step from their personal library (or types one quickly)
4. Senior hits "Send to [Junior]" → step appears as notification in junior's session
5. Junior sees: "Michael sent you a step: Check Citrix Broker Service binding"
6. One click to insert into session
7. Step documented in export, optionally saved to junior's library
### Why
Replaces unstructured Teams/Slack troubleshooting advice with structured, documented, reusable steps. The knowledge stays in the system.
### Implementation Notes
- Requires lightweight notification/inbox system (polling or WebSocket)
- Builds on: Share Progress (Idea 2) + Step Library (existing)
- New API: `POST /api/v1/sessions/{id}/send-step`
- Frontend: notification badge + step insertion flow
### Analytics Potential
- Which seniors send the most steps (mentorship tracking)
- Which pushed steps get reused (knowledge value)
- Which juniors receive fewer pushes over time (skill growth)
---
## Idea 5: Path Analytics — "The Road Most Traveled"
**Category:** Intelligence
**Effort:** Medium
### What
Aggregate completed session data to show statistical hints on decision nodes:
- Badge: "78% of engineers chose Option B here"
- On solution nodes: "Resolved the issue 92% of the time"
- At common stuck points: "Engineers often add a custom step here"
### Why
Delivers on the tagline. For a junior engineer staring at three options, seeing "most engineers went this way" is a confidence boost. For tree authors, analytics reveal dead-end branches.
### Implementation Notes
- Aggregate query on session `path_taken` and `decisions` JSONB
- Compute per-node: choice distribution, resolve rate, custom step frequency
- Cache aggregates (recompute daily or on-demand)
- Display as subtle badges on decision nodes (not intrusive)
- Resolve rate: track whether session completed at a solution node + optional "did this fix it?" prompt
### Data Requirements
- Needs sufficient session volume per tree to be statistically meaningful (suggest: show after 10+ sessions)
- Weight recent sessions higher than old ones
---
## Idea 6: Session Scratchpad
**Category:** Context capture
**Effort:** Small-Medium
**Priority:** Must-have (per Michael)
### What
A persistent sidebar during active sessions for capturing ambient data: IP addresses, error codes, server names, usernames — anything that doesn't fit a specific decision node's notes field.
### Why
During troubleshooting, engineers accumulate bits of data (from `ipconfig`, Event Viewer, phone conversations) that live on sticky notes or in their head. This gives it a home and includes it in the export.
### Export Output
```
## Evidence / Reference
- Server IP: 192.168.1.50
- Error code: 0x80070005
- Affected user: jsmith@contoso.com
- Event ID: 4625 (repeated 47 times in last hour)
```
### Implementation Notes
- Persistent sidebar (collapsible) in TreeNavigationPage
- Store in session JSONB as `scratchpad` array of entries
- Each entry: text + optional label + timestamp
- Include in export as "Evidence / Reference" section
- Start simple: just a text area with "Add Note" button
- Future: structured key-value pairs, tags, image paste
### Pairs With
- Command Output Capture (Idea 3): structured output at nodes + freeform notes in scratchpad = complete evidence
- Share Progress (Idea 2): scratchpad content included in shared summary
---
## Idea 7: Multi-Tree Sessions
**Category:** Session enhancement
**Effort:** Large
### What
When troubleshooting reveals the problem is in a different domain, branch into another tree mid-session without losing context. The export captures the entire journey.
### Workflow
1. Engineer is in "VPN Issues" tree, 5 steps deep
2. Discovers the actual problem is DNS, not VPN
3. Clicks "Open Related Tree" → selects "DNS Resolution Issues"
4. Current tree bookmarked, linked session starts in DNS tree
5. DNS session completes → returns to VPN tree where they left off
6. Export shows unified narrative with both trees
### Export Output
```
## VPN Connection Issues
1. Verified VPN client version: OK
2. Checked tunnel status: UP
3. Tested connectivity through tunnel: FAIL
→ Branched to: DNS Resolution Issues
## DNS Resolution Issues (linked)
1. Ran nslookup: timeout
2. Checked DNS config: wrong DC
3. Resolution: Updated DNS to 10.0.0.5
4. Returned to VPN - retested: PASS
5. Resolution: DNS misconfiguration causing apparent VPN failure
```
### Implementation Notes
- Session model needs: `parent_session_id`, `branched_at_node_id`
- "Open Related Tree" action on any node (tree selector modal)
- Breadcrumb shows tree chain: VPN > DNS
- Export renderer handles nested/linked sessions
- Back button returns to parent session at bookmark point
### Why
Real troubleshooting rarely stays in one domain. This captures the full diagnostic story.
---
## Idea 8: Recurring Issue Detection
**Category:** Intelligence
**Effort:** Small-Medium
### What
When an engineer starts a session and enters a client name, show previous sessions for that client in that tree. If the same resolution keeps being reached, prompt for root cause action.
### UI
- At session start: "3 previous sessions for Warner Robins in this tree (last: Jan 28)" + link to view
- At resolution (if recurring): "This is the 3rd time this issue was resolved the same way for this client. Consider documenting a permanent fix or escalating to address root cause."
### Why
Turns ResolutionFlow from reactive (fix the ticket) to proactive (fix the root cause). For MSP managers, recurring issues per client = business intelligence for infrastructure upgrade proposals.
### Implementation Notes
- Query: sessions grouped by client_name + tree_id, count + last date
- Display at session start (inline, not blocking)
- Recurrence prompt: compare resolution node_id across sessions
- Future (with PSA/RMM): correlate with alert data for richer signals
### Data Model
- No schema changes needed — query existing sessions table
- Optional: normalize client names (fuzzy match or client_id foreign key)
---
## Idea 9: Tree Health Scores
**Category:** Intelligence
**Effort:** Medium
### What
Data-driven health indicators for trees, surfaced to tree authors and admins.
### Signals
- **Custom step frequency**: Engineers keep adding steps at the same node → tree is missing a branch
- **Abandonment rate**: Sessions started but not completed → tree isn't leading to resolutions
- **Low resolve rate**: Solution nodes that don't actually fix issues
- **Staleness**: No updates in X months for a technology area that changes frequently
- **Escalation rate**: High percentage of sessions shared/escalated from this tree
### Display
- Green/yellow/red health badge on tree library cards
- Author notification: "Your 'VPN Issues' tree has yellow health — 4 engineers added custom steps at 'Check Split Tunnel Config' this month"
- Admin dashboard: team-wide tree health overview
### Why
Creates a self-improving ecosystem. Sessions generate data → data identifies weak trees → authors improve trees → next engineer gets a better experience.
### Implementation Notes
- Scheduled aggregation job (daily)
- Health score algorithm: weighted combination of signals
- Store as computed field on tree (or separate analytics table)
- Notification system (pairs with Push Steps notification infrastructure, Idea 4)
---
## Idea 10: AI Tree Intelligence (Long-term Vision)
**Category:** Intelligence
**Effort:** Large (phased)
**Status:** Ultimate goal
### What
Three layers of AI, each building on the last, leveraging ResolutionFlow's unique structured troubleshooting dataset.
### Layer 1: Smart Tree Suggestions
- Engineer pastes ticket description: "User at Warner Robins reports Outlook keeps crashing after latest update"
- AI suggests: "Recommended: Outlook/Email Issues tree → Start at 'Recent Update' branch"
- Not just which tree — which branch to start at, skipping generic initial questions
- **Implementation:** NLP parsing of ticket text, match against tree node content and tags
### Layer 2: Session-Driven Tree Evolution
- Aggregate session data reveals patterns: "35% of engineers add 'Check MFA Token' after 'Auth Failed' node, and it resolves 80% of the time"
- Generate suggestion to tree author: "Recommended new branch based on 18 successful sessions"
- Author reviews and approves with one click — tree evolves from real usage
- **Implementation:** Aggregation queries + LLM formatting suggestions + author approval UI
### Layer 3: AI Tree Generation
- Senior describes: "We keep getting Azure AD Sync issues, no tree exists"
- AI generates complete tree draft using:
- Similar trees in the system
- Custom steps engineers have created for Azure AD
- Resolution patterns from session history
- Real PowerShell commands from command output captures
- Senior reviews, tweaks, publishes
- **Implementation:** RAG over tree corpus + session data + LLM generation + tree editor integration
### Why This Is the Moat
ConnectWise or IT Glue could build a decision tree tool. But they don't have hundreds of structured session paths with outcomes to learn from. ResolutionFlow's data is structured by design — decision trees + session paths + outcomes — not unstructured ticket notes. That's a dataset purpose-built for learning optimal troubleshooting paths.
### Phasing
- Layer 1 can ship independently with basic NLP
- Layer 2 needs sufficient session volume (6+ months of real usage)
- Layer 3 needs Layers 1 + 2 data + LLM integration
---
## Quick Actions Dashboard (Bonus)
**Category:** UX improvement
**Effort:** Medium
### What
Replace the tree library as the default landing page with a troubleshooting command center.
### Sections
- **Resume sessions** — "VPN Issues - Acme Corp (started 20 min ago)" for incomplete sessions
- **Quick starts** — Frequent tree+client combos: "File Share Access for Warner Robins — Quick start?" One tap.
- **Team activity** — "Sarah completed 'AD Replication' for Client X (12 min)" — visibility without a standup
- **Your trees** — Health scores for trees you authored, pending suggestions from AI (Layer 2)
- **Recurring alerts** — Clients with repeat issues that need attention
### Why
Turns ResolutionFlow from a tool you visit per-ticket into something you keep open all day. Reduces friction from "open app → find tree → start session" to "open app → click the obvious next action."
---
## Suggested Build Order
### Near-term (build now, small effort, immediate value)
1. **Session Scratchpad** (Idea 6) — must-have per Michael
2. **Session Time Tracking** (Idea 1) — quick win, enhances every export
3. **Command Output Capture** (Idea 3) — quick win, pairs with scratchpad
### Mid-term (build next, medium effort, high value)
1. **Share Progress / Escalation** (Idea 2) — daily use for team collaboration
2. **Recurring Issue Detection** (Idea 8) — leverages existing data immediately
3. **Quick Actions Dashboard** (Bonus) — improves daily UX
4. **Path Analytics** (Idea 5) — needs session volume, start collecting data now
### Later (larger effort, needs foundation)
1. **Push Steps to Active Sessions** (Idea 4) — needs notification system
2. **Tree Health Scores** (Idea 9) — needs session volume + analytics infrastructure
3. **Multi-Tree Sessions** (Idea 7) — complex UX, large refactor
### Long-term vision
1. **AI Tree Intelligence** (Idea 10) — phased rollout, ultimate differentiator
---
## Dependencies & Connections
```
Scratchpad (6) ──────────────────────────────┐
Command Output (3) ──────────────────────────┤
Time Tracking (1) ───────────────────────────┤── Enhanced Exports
Share Progress (2) ──┬── Push Steps (4) ─────┤── Collaboration
│ │
└── Notification System ─┘
Path Analytics (5) ──┬── Tree Health (9) ────┬── AI Intelligence (10)
Recurring Issues (8) ┘ │
Multi-Tree Sessions (7) ────────────────────┘
```
Key insight: Ideas 1, 3, and 6 (time tracking, command output, scratchpad) are independent quick wins that make exports richer. Ideas 2 and 4 (share progress, push steps) build a collaboration layer. Ideas 5, 8, 9, and 10 (analytics, recurring issues, health, AI) form the intelligence layer that grows with usage.
---
*Generated during brainstorming session, February 4, 2026*

View File

@@ -1,228 +0,0 @@
# Project Review — February 6, 2026
> Comprehensive audit of Patherly/ResolutionFlow codebase, comparing implementation against specs, roadmap, and GitHub issues.
---
## Executive Summary
ResolutionFlow is well past the MVP stage and deep into Phase 2.5 (Step Library). The backend is robust with 61 passing tests, strong security hardening, and complete API coverage. The frontend has all core features working with a recent responsive design overhaul. Key gaps are: outdated project documentation, several Phase 2.5 features still open, and the roadmap checkboxes not updated to reflect actual progress.
**Overall Health: Strong** — the codebase is production-ready and deployed on Railway.
---
## 1. Backend Review
### Test Results
- **61/61 tests passing** (73s runtime)
- Zero failures, zero errors
- Tests cover: auth, trees CRUD, sessions, export, categories, tags, folders, steps, admin, invite codes, permissions
### API Endpoints (All Implemented & Working)
| Area | Endpoints | Status |
|------|-----------|--------|
| Auth | register, login, refresh, logout, me | Complete |
| Trees | list, get, create, update, delete, search | Complete |
| Sessions | list, get, start, track, complete, export, scratchpad | Complete |
| Categories | list, get, create, update, delete | Complete |
| Tags | list, create, delete, autocomplete | Complete |
| Folders | list, get, create, update, delete (cascade) | Complete |
| Step Categories | list, get, create, update, delete | Complete |
| Steps | list, get, create, update, delete, search, rate, popular-tags | Complete |
| Admin | list users, get user, change role, toggle team admin, deactivate, activate | Complete |
| Invite Codes | list, create, validate | Complete |
### Security Hardening (All Phases Complete)
| Item | Status |
|------|--------|
| Phase A: Registration role hardcoded | Done |
| Phase A: HTML export XSS fix | Done |
| Phase A: Secret key validator | Done |
| Phase A: Role CHECK constraint | Done |
| Phase B: Tree access check on sessions | Done |
| Phase B: Centralized permissions.py | Done |
| Phase B: is_active field + enforcement | Done |
| Phase B: Admin endpoints | Done |
| Phase B: Rate limiting (slowapi) | Done |
| Phase B: Refresh token rotation (JTI) | Done |
| Phase C: Super admin bypass in tree filter | Done |
| Phase C: Audit log table | Done |
| Phase C: Soft delete for trees | Done |
| Phase D: Password complexity validation | Done |
| Phase D: Soft delete cascade cleanup | Done |
| Phase D: Debug endpoint gated | Done |
| Phase D: SQL wildcard escaping | Done |
### Backend Code Quality
- No TODOs or FIXMEs in codebase
- Consistent use of timezone-aware datetimes
- All endpoints use `get_current_active_user` (not the ungated `get_current_user`)
- Pydantic v2 schemas throughout
- Async SQLAlchemy with proper lazy-loading avoidance
### Backend Gaps
- **Tree forking endpoint** — specified in Phase 2.5, not yet implemented (Issue #13)
- **Save session as tree** — not yet implemented (Issue #17)
- **Share token / public tree access** — not yet implemented (Issue #16)
- **Tree usage statistics** — no analytics endpoints exist yet
- **Draft tree status** — designed (Issue #25) but not implemented
---
## 2. Frontend Review
### Pages Summary
| Page | Route | Status |
|------|-------|--------|
| LoginPage | /login | Complete |
| RegisterPage | /register | Complete |
| TreeLibraryPage | /trees | Complete |
| TreeEditorPage | /trees/new, /trees/:id/edit | Complete |
| TreeNavigationPage | /trees/:id/navigate | Complete |
| SessionHistoryPage | /sessions | Complete |
| SessionDetailPage | /sessions/:id | Complete |
| SettingsPage | /settings | Complete |
### Component Inventory
| Directory | Components | Status |
|-----------|-----------|--------|
| layout/ | AppLayout, ProtectedRoute, BrandLogo, BrandWordmark | Complete |
| common/ | Modal, ConfirmDialog, ThemeToggle, ErrorBoundary, TagInput, TagBadges | Complete |
| tree-editor/ | TreeEditorLayout, NodeList, NodeEditorModal, NodeForm*, DynamicArrayField, NodePicker, ValidationSummary | Complete |
| tree-preview/ | TreePreviewPanel, TreePreviewNode | Complete |
| step-library/ | CustomStepModal, StepForm, StepLibraryBrowser, StepCard, StepDetailModal | Complete |
| session/ | ScratchpadSidebar, PostStepActionModal, ContinuationModal, ExportPreviewModal, ForkTreeModal | Complete |
| library/ | FolderSidebar, FolderEditModal, AddToFolderMenu | Complete |
### Frontend Build Status
- TypeScript compilation: Clean (0 errors)
- Vite build: Success
- Lint: 0 new errors (8 pre-existing, all in untouched files)
### Recently Completed (This Session)
- Mobile hamburger menu + nav drawer
- Responsive modal system (full-width on mobile)
- Scratchpad full-screen mobile overlay
- Folder sidebar mobile slide-over
- Tree editor mobile gate ("Desktop Required")
- Touch target improvements throughout
- CSS animations (fade-in, slide-in, scale-in)
- Card hover lift effects
- Standardized page padding and heading sizes
- CustomStepModal full-screen on mobile
### Frontend Gaps
- **Rate/review modal after step use** — not implemented (Issue #19)
- **Admin category management UI** — not implemented (Issue #18)
- **My Trees dashboard** — not implemented (Issue #15)
- **Tree sharing modal** — not implemented (Issue #16)
- **Sort options in tree library** — no sort dropdown (by usage, date, name)
- **Export preview/copy from session detail** — preview works, copy works, but clipboard from tree nav page not wired
- **Keyboard shortcuts in tree nav** — partially implemented (1-9 for options, Esc for back) but no visible hint on first load
### Pre-existing Lint Warnings (8 errors, 10 warnings)
All pre-existing, in files not touched by this session:
- `@typescript-eslint/no-explicit-any` (3 occurrences)
- `@typescript-eslint/no-unused-vars` (2 occurrences)
- `react-hooks/set-state-in-effect` (1 in NodeEditorModal)
- `@typescript-eslint/no-empty-object-type` (1 in types/step.ts)
- `react-hooks/exhaustive-deps` warnings (10, all pre-existing)
---
## 3. Documentation Accuracy
### CURRENT-STATE.md — SIGNIFICANTLY OUTDATED
Last updated January 29, 2026. Major inaccuracies:
- Says "Phase 2 - Tree Editor (In Progress)" — actually in Phase 2.5
- Says "40+ integration tests" — actually 61
- Missing: Categories, tags, folders, step library, RBAC, security hardening, scratchpad, responsive design, Railway deployment
- Missing all Phase C/D security work
- File structure section is stale (missing many new files)
### 03-DEVELOPMENT-ROADMAP.md — PARTIALLY OUTDATED
Many checkboxes not updated:
- Phase 1 deployment marked unchecked — actually deployed on Railway
- Phase 2 team features marked unchecked — RBAC is fully implemented
- Phase 2 tree library browser marked unchecked — fully implemented with categories, tags, folders
- Phase 2 session history marked unchecked — fully implemented
- Phase 2 mobile responsive marked unchecked — just implemented
- Phase 2.5 step library all unchecked — backend 100% done, frontend mostly done
- Export preview/copy marked unchecked — actually implemented
### CLAUDE.md — ACCURATE
This is the most up-to-date document. Well-maintained, reflects current state accurately.
### LESSONS-LEARNED.md — ACCURATE
Comprehensive bug fix reference, still relevant.
---
## 4. GitHub Issues Analysis
### Open Issues (7)
| # | Title | Priority | Status Assessment |
|---|-------|----------|-------------------|
| 25 | Draft trees and custom steps | Medium | Not started — design doc exists |
| 19 | Rate/review modal after step use | Low | Not started |
| 18 | Admin category management UI | Low | Not started |
| 17 | Save session as custom tree | Low | Not started |
| 16 | Tree sharing via link | Medium | Not started |
| 15 | My Trees dashboard page | Medium | Not started |
| 13 | Tree forking API endpoint | Medium | Not started (ForkTreeModal exists in frontend but no backend) |
### Closed Issues (17) — All Properly Resolved
Issues #2-12, #14, #20-23 are all correctly closed and implemented in the codebase.
### Missing Issues (Features That Should Be Tracked)
These features exist in the roadmap/specs but have no GitHub issues:
1. **Update outdated documentation** — CURRENT-STATE.md and ROADMAP.md are stale
2. **Tree usage statistics/analytics** — mentioned in Phase 2/3, no issue
3. **Sort options in tree library** — sort by usage, date, name
4. **Export preview from tree navigation** — export only from session detail page
5. **Keyboard shortcuts documentation** — shortcuts exist but no help overlay
6. **Fix pre-existing lint errors** — 8 errors in codebase
7. **Code splitting / bundle optimization** — bundle is 673KB (warning threshold 500KB)
8. **Mobile responsive polish** — further refinement after initial pass
---
## 5. Priority Recommendations
### High Priority (Should Do Next)
1. **Update CURRENT-STATE.md** — severely outdated, misleads any new contributor
2. **Update 03-DEVELOPMENT-ROADMAP.md checkboxes** — many completed items still unchecked
3. **Fix pre-existing lint errors** (8 errors) — clean build discipline
### Medium Priority (Phase 2.5 Completion)
4. **Tree forking API** (Issue #13) — ForkTreeModal exists in frontend, needs backend
5. **My Trees dashboard** (Issue #15) — natural next feature
6. **Tree sharing via link** (Issue #16) — increases adoption
### Lower Priority (Polish)
7. **Rate/review modal** (Issue #19) — backend exists, needs frontend trigger
8. **Admin category management UI** (Issue #18) — backend exists, needs frontend
9. **Draft trees** (Issue #25) — nice workflow improvement
10. **Bundle size optimization** — code splitting for the 673KB JS bundle
11. **Save session as tree** (Issue #17) — interesting but complex
---
## 6. What's Working Well
- **Backend architecture**: Clean, well-tested, comprehensive API
- **Security**: Multiple hardening phases completed, audit logging, rate limiting
- **Permission system**: Centralized RBAC with proper role hierarchy
- **Frontend UX**: Tree navigation flow is smooth, editor is full-featured
- **Session management**: Scratchpad, decisions tracking, export all working
- **Brand consistency**: ResolutionFlow theme applied throughout
- **Deployment**: Railway auto-deploy on push to main, PR environments
- **CLAUDE.md**: Excellent project context doc — kept accurate

View File

@@ -1,427 +0,0 @@
# Tier 1 UX Enhancement: Consistent Notification System
## Context
ResolutionFlow (Patherly) currently lacks a consistent feedback system for user actions. MSP engineers switching between multiple contexts need immediate confirmation that actions succeeded to reduce cognitive load and prevent errors. Research shows that 200-300ms feedback timing dramatically improves perceived reliability and user confidence.
### Current State Problems
- **No success notifications**: Users don't get confirmation when trees/folders/sessions are saved or deleted
- **Inconsistent patterns**: Mix of button state changes, modal errors, and page-level banners
- **Silent operations**: "Add to Folder" silently succeeds with no feedback
- **No dismissible errors**: Error messages persist until page reload or modal close
- **No notification history**: Users can't review recent actions
### User Impact
Engineers experience cognitive overload troubleshooting client issues. Without clear feedback:
- They second-guess themselves and repeat actions
- They abandon tasks when uncertain if changes saved
- They miss critical errors that appear briefly
- They waste time verifying actions succeeded
### Why This Feature First
1. **Foundational infrastructure**: Toast system benefits all future features
2. **Immediate wins**: Every save/delete/export action gets better UX
3. **Low risk**: Additive feature, doesn't break existing flows
4. **Quick implementation**: 1-2 days to full deployment
5. **High perceived value**: Users notice and appreciate immediate feedback
---
## Design Overview
We'll integrate **sonner** (by shadcn creator) - a modern, accessible toast notification library that:
- Works perfectly with Tailwind CSS and our dark mode
- Provides beautiful default styling matching our design system
- Supports promise tracking (show loading → success/error automatically)
- Handles stacking, positioning, and auto-dismiss elegantly
- Only ~10KB gzipped
- Fully accessible (ARIA attributes, keyboard navigation)
### Architecture: Toast as Global Service
```
frontend/src/
├── main.tsx
│ └── Wrap <App> with <Toaster /> provider
├── lib/
│ └── toast.ts (re-export sonner's toast with custom defaults)
├── components/
│ ├── library/TreeLibraryPage.tsx (add toast.success on delete)
│ ├── library/FolderEditModal.tsx (add toast.success on save)
│ ├── tree-editor/TreeEditorPage.tsx (replace error banner with toast)
│ ├── session/SessionDetailPage.tsx (add toast on export)
│ └── ...other components using toast
└── api/
└── client.ts (optional: global error toast interceptor)
```
**Key Design Decisions:**
1. **Single import point**: `import { toast } from '@/lib/toast'` everywhere
2. **Custom defaults**: Pre-configured duration, position, dark mode sync
3. **Promise pattern**: Use `toast.promise()` for async operations
4. **Consistent vocabulary**: "Saved", "Deleted", "Exported" (not "Success!")
5. **Error details**: Show action + reason ("Failed to delete tree: Network error")
---
## Implementation Plan
### Phase 1: Install and Configure Sonner
**1.1 Install dependencies**
```bash
cd patherly/frontend
npm install sonner
```
**1.2 Create toast utility wrapper** (`frontend/src/lib/toast.ts`):
```typescript
import { toast as sonnerToast } from 'sonner';
// Re-export with custom defaults
export const toast = {
success: (message: string, options?: any) =>
sonnerToast.success(message, { duration: 4000, ...options }),
error: (message: string, options?: any) =>
sonnerToast.error(message, { duration: 6000, ...options }),
info: (message: string, options?: any) =>
sonnerToast.info(message, { duration: 4000, ...options }),
loading: (message: string, options?: any) =>
sonnerToast.loading(message, { ...options }),
promise: sonnerToast.promise,
dismiss: sonnerToast.dismiss
};
```
**1.3 Add Toaster provider** to `frontend/src/main.tsx`:
```typescript
import { Toaster } from 'sonner';
// Inside root render, wrap App:
<React.StrictMode>
<Toaster
position="top-right"
richColors
closeButton
theme={themeStore.theme === 'dark' ? 'dark' : 'light'}
/>
<App />
</React.StrictMode>
```
**1.4 Sync theme with Toaster**: Update `themeStore.ts` to re-render Toaster on theme change
---
### Phase 2: Add Notifications to Core Actions
**Success notifications to add:**
| Component | Action | Toast Message | Type |
|-----------|--------|---------------|------|
| TreeLibraryPage | Delete tree | "Tree deleted" | success |
| FolderEditModal | Save folder | "Folder saved" | success |
| FolderSidebar | Delete folder | "Folder deleted" | success |
| AddToFolderMenu | Add tree to folder | "Added to {folderName}" | success |
| AddToFolderMenu | Remove from folder | "Removed from {folderName}" | success |
| TreeEditorPage | Save tree | "Tree saved" | success |
| TreeEditorPage | Publish tree | "Tree published" | success |
| SessionDetailPage | Export session | "Session exported" | success |
| SettingsPage | Save preferences | "Settings saved" | success |
| AccountSettingsPage | Update account | "Account updated" | success |
**2.1 Tree Library Actions** ([TreeLibraryPage.tsx](patherly/frontend/src/pages/TreeLibraryPage.tsx)):
- Remove confirmation dialog result logging
- Add `toast.success('Tree deleted')` after successful delete
- Replace inline error state with `toast.error(error)`
**2.2 Folder Management** ([FolderEditModal.tsx](patherly/frontend/src/components/library/FolderEditModal.tsx), [FolderSidebar.tsx](patherly/frontend/src/components/library/FolderSidebar.tsx)):
- Add success toast on folder create/update/delete
- Remove inline error messages (use toast.error instead)
- Keep loading state but add toast feedback on completion
**2.3 Tree Editor** ([TreeEditorPage.tsx](patherly/frontend/src/pages/TreeEditorPage.tsx)):
- Replace current error banner with toast notifications
- Add `toast.promise()` for autosave operations:
```typescript
toast.promise(saveTree(), {
loading: 'Saving tree...',
success: 'Tree saved',
error: 'Failed to save tree'
});
```
- Remove `saveError` state and error banner div
**2.4 Session Export** ([SessionDetailPage.tsx](patherly/frontend/src/pages/SessionDetailPage.tsx)):
- Add toast on successful export: `toast.success('Session exported')`
- Add toast on copy to clipboard: `toast.success('Copied to clipboard')`
- Replace current inline copy feedback with toast
**2.5 Settings Pages**:
- Add success toasts on settings save
- Remove inline success messages if any exist
---
### Phase 3: Standardize Error Handling
**3.1 Global API Error Interceptor** ([client.ts](patherly/frontend/src/api/client.ts)):
```typescript
apiClient.interceptors.response.use(
response => response,
error => {
// Show toast for non-form errors (4xx/5xx)
const message = error.response?.data?.detail || 'An error occurred';
// Don't toast validation errors (handled inline)
if (error.response?.status !== 422) {
toast.error(message);
}
return Promise.reject(error);
}
);
```
**3.2 Remove redundant error handling**:
- Keep form validation errors inline (modal errors)
- Use toast for unexpected/network errors
- Remove page-level error banner states where replaced by toast
---
### Phase 4: Add Copy Feedback Consistency
**4.1 Standardize clipboard operations**:
All "Copy to Clipboard" buttons should:
1. Use `navigator.clipboard.writeText()`
2. Show toast on success: `toast.success('Copied to clipboard')`
3. Show toast on error: `toast.error('Failed to copy')`
4. Remove button state toggle (icon + text change)
**Components to update:**
- [SessionDetailPage.tsx](patherly/frontend/src/pages/SessionDetailPage.tsx) (export copy button)
- [ExportPreviewModal.tsx](patherly/frontend/src/components/session/ExportPreviewModal.tsx) (copy button)
- [AddToFolderMenu.tsx](patherly/frontend/src/components/library/AddToFolderMenu.tsx) (if any copy functionality)
---
## Critical Files to Modify
| File Path | Changes |
|-----------|---------|
| `patherly/frontend/package.json` | Add `sonner` dependency |
| `patherly/frontend/src/main.tsx` | Add `<Toaster>` provider component |
| `patherly/frontend/src/lib/toast.ts` | **NEW FILE** - Toast utility wrapper |
| `patherly/frontend/src/store/themeStore.ts` | Sync theme changes to Toaster |
| `patherly/frontend/src/api/client.ts` | Add global error interceptor |
| `patherly/frontend/src/pages/TreeLibraryPage.tsx` | Add success/error toasts for delete |
| `patherly/frontend/src/pages/TreeEditorPage.tsx` | Replace error banner with toast.promise() |
| `patherly/frontend/src/pages/SessionDetailPage.tsx` | Add export/copy success toasts |
| `patherly/frontend/src/pages/SettingsPage.tsx` | Add settings save toast |
| `patherly/frontend/src/pages/AccountSettingsPage.tsx` | Add account update toast |
| `patherly/frontend/src/components/library/FolderEditModal.tsx` | Add folder save toast, remove inline errors |
| `patherly/frontend/src/components/library/FolderSidebar.tsx` | Add delete folder toast |
| `patherly/frontend/src/components/library/AddToFolderMenu.tsx` | Add add/remove from folder toasts |
| `patherly/frontend/src/components/session/ExportPreviewModal.tsx` | Standardize copy feedback |
---
## Design Patterns and Best Practices
### When to Use Each Toast Type
**Success (green, 4s duration)**:
- Action completed successfully
- Examples: "Tree saved", "Folder deleted", "Exported"
- Only show for user-initiated actions, not automatic operations
**Error (red, 6s duration)**:
- Action failed unexpectedly
- Examples: "Failed to save tree: Network error", "Could not delete folder"
- Include reason when available from API response
**Info (blue, 4s duration)**:
- Neutral information
- Examples: "Session resumed", "Autosave enabled"
- Use sparingly - don't spam user with info toasts
**Loading (infinite duration until dismissed)**:
- Long-running operations (>500ms expected)
- Examples: "Saving tree...", "Exporting session..."
- Use `toast.promise()` to automatically transition to success/error
### Promise Pattern Best Practice
For async operations with loading states:
```typescript
// DON'T do this (manual dismiss logic):
const toastId = toast.loading('Saving...');
try {
await saveTree();
toast.success('Saved', { id: toastId });
} catch (error) {
toast.error('Failed', { id: toastId });
}
// DO this (automatic state transitions):
toast.promise(saveTree(), {
loading: 'Saving tree...',
success: 'Tree saved',
error: (err) => `Failed to save: ${err.message}`
});
```
### Error Message Format
Always provide context:
- ❌ "Error" (too vague)
- ❌ "Failed" (what failed?)
- ✅ "Failed to delete tree: Permission denied"
- ✅ "Could not load folders: Network error"
### When NOT to Use Toasts
Keep inline error messages for:
1. **Form validation errors**: Show next to invalid field
2. **Modal-contained errors**: Errors within a modal workflow
3. **Real-time feedback**: Input validation as user types
4. **Critical blocking errors**: Full-page error states
---
## Verification Plan
### Manual Testing Checklist
**Tree Operations:**
- [ ] Create new tree → "Tree saved" toast appears
- [ ] Edit existing tree → "Tree saved" toast appears
- [ ] Delete tree → Confirm dialog → "Tree deleted" toast appears
- [ ] Delete fails (network off) → "Failed to delete tree" toast with reason
- [ ] Autosave triggers → "Tree saved" toast (via promise pattern)
**Folder Operations:**
- [ ] Create folder → "Folder saved" toast appears
- [ ] Edit folder name → "Folder saved" toast appears
- [ ] Delete folder → "Folder deleted" toast appears
- [ ] Add tree to folder → "Added to [folder name]" toast appears
- [ ] Remove tree from folder → "Removed from [folder name]" toast appears
**Session Operations:**
- [ ] Export session (any format) → "Session exported" toast appears
- [ ] Copy to clipboard → "Copied to clipboard" toast appears
- [ ] Copy fails (permissions) → "Failed to copy" toast appears
- [ ] Download session → "Session exported" toast appears
**Settings:**
- [ ] Save user preferences → "Settings saved" toast appears
- [ ] Update account details → "Account updated" toast appears
**Error Scenarios:**
- [ ] Network offline → API errors show toast with meaningful message
- [ ] Permission denied → Toast shows "Permission denied" reason
- [ ] 500 server error → Toast shows generic "Server error" message
- [ ] 422 validation error → NO toast (inline validation only)
**Dark Mode:**
- [ ] Switch to dark mode → Toast background is dark themed
- [ ] Switch to light mode → Toast background is light themed
- [ ] Toast colors match theme (success green, error red, etc.)
**Accessibility:**
- [ ] Screen reader announces toast messages (ARIA live region)
- [ ] Toasts can be dismissed with keyboard (close button focusable)
- [ ] Toast position doesn't obscure critical UI (top-right safe zone)
- [ ] Color contrast meets WCAG AA standards
**Stacking Behavior:**
- [ ] Multiple toasts stack vertically without overlap
- [ ] Oldest toasts auto-dismiss while newer ones remain
- [ ] Maximum 3-4 toasts visible at once (sonner default)
---
## Rollout Strategy
### Phase 1: Core Infrastructure (Day 1, Morning)
1. Install sonner package
2. Create toast utility wrapper (`lib/toast.ts`)
3. Add Toaster provider to `main.tsx`
4. Sync with theme store
5. Test basic toast functionality in dev
### Phase 2: High-Impact Actions (Day 1, Afternoon)
1. Tree save/delete operations
2. Folder CRUD operations
3. Session export/copy
4. Remove old error banners
### Phase 3: Error Standardization (Day 2, Morning)
1. Add global API error interceptor
2. Clean up redundant error handling
3. Test error scenarios (network offline, permissions, etc.)
### Phase 4: Refinement (Day 2, Afternoon)
1. Verify all toast messages use consistent vocabulary
2. Check dark mode appearance
3. Test accessibility with screen reader
4. Performance check (ensure no memory leaks from unmounted toasts)
5. Update user documentation if needed
---
## Future Enhancements (Out of Scope)
These can be added later if needed:
- **Undo actions**: Toast with "Undo" button for reversible operations
- **Progress toasts**: Show percentage for uploads/exports
- **Grouped toasts**: Collapse multiple similar actions ("3 trees deleted")
- **Persistent notifications**: Critical alerts that don't auto-dismiss
- **Sound effects**: Subtle audio feedback (accessibility feature)
- **Action buttons in toasts**: "View details" or "Retry" buttons
---
## Risks and Mitigation
| Risk | Impact | Mitigation |
|------|--------|------------|
| **Toast spam** | Users annoyed by too many notifications | Only toast user-initiated actions, not automatic events |
| **Theme sync issues** | Toast theme doesn't match app theme | Subscribe to themeStore changes, update Toaster theme prop |
| **Mobile viewport** | Toasts obscure content on small screens | Use `top-right` position (least obtrusive), verify on mobile |
| **Bundle size increase** | +10KB to frontend bundle | Acceptable for significant UX improvement; sonner is already optimized |
| **Breaking existing error handling** | Some errors go unnoticed | Keep inline validation errors; only replace page-level banners |
---
## Success Metrics
**Qualitative:**
- Users report feeling more confident about action completion
- Support tickets decrease for "Did my changes save?" questions
- User feedback mentions improved responsiveness
**Quantitative:**
- 0 console errors related to toast implementation
- Toast render time <50ms (measured in React DevTools)
- No accessibility violations in Lighthouse audit
- Frontend bundle size increase <15KB gzipped
---
## References
- **Sonner Documentation**: https://sonner.emilkowal.ski/
- **shadcn/ui Toast Component**: https://ui.shadcn.com/docs/components/sonner
- **UX Research on Feedback Timing**: Nielsen Norman Group - "Response Times: The 3 Important Limits"
- **Accessibility Guidelines**: WCAG 2.1 Success Criterion 4.1.3 (Status Messages)

View File

@@ -1,285 +0,0 @@
# 10x Analysis: ResolutionFlow
Session 1 | Date: 2026-02-10
## Current Value
ResolutionFlow guides MSP engineers through structured troubleshooting decision trees, captures every step/decision/note, and exports professional ticket documentation. Target: Michael Chihlas uses it for 50% of tickets within 3 months.
**Core loop:** Receive ticket → pick tree → follow guided path → take notes → export documentation → paste into PSA.
**What works well:** The guided troubleshooting UX is solid. Tree editor supports rich content (markdown, PowerShell commands). Session capture is comprehensive. Export formats cover common needs. Step Library enables knowledge reuse. RBAC/multi-tenant foundations are strong.
## The Question
What would make this 10x more valuable — from "useful documentation tool" to "indispensable MSP operating system"?
---
## Massive Opportunities
### 1. Intelligence Loop: Session Data → Actionable Insights → Better Trees
**What**: Every session captures rich data (path taken, time per step, outcomes, custom steps created) but today it evaporates after export. Build an analytics engine that mines session data to: surface trending issues, identify dead-end tree branches, recommend tree improvements, and predict what tree an engineer needs based on ticket keywords.
**Why 10x**: This transforms ResolutionFlow from a static playbook into a **learning system**. Every session makes the product smarter. MSP managers get visibility they've never had — which trees work, which engineers are efficient, which clients have recurring problems. This is the moat: the more sessions run, the more valuable the platform becomes. No competitor can replicate your accumulated operational intelligence.
**Unlocks**:
- "Smart tree picker" — paste ticket description, get recommended tree
- Tree health scores — identify underperforming branches
- Auto-detect when custom steps should become permanent tree branches
- Manager dashboards with real ROI metrics (time saved, resolution rates)
**Effort**: High (analytics pipeline, dashboards, recommendation engine)
**Risk**: Need enough session volume to be useful. Cold start problem for new teams.
**Score**: 🔥 **Must do** — this is THE compounding moat
---
### 2. PSA Integration: One-Click Ticket Documentation
**What**: Direct API integration with ConnectWise, Autotask, Kaseya BMS, and HaloPSA. When a session completes, one click pushes formatted documentation directly into the ticket. Better yet: start sessions FROM a ticket (pull ticket number, client name, issue description automatically).
**Why 10x**: The current workflow breaks at the last mile — engineer copies export text, switches to PSA, pastes. This friction is the #1 adoption killer. Every extra click is a reason to skip ResolutionFlow and just wing it. Direct PSA integration makes the tool frictionless AND positions ResolutionFlow inside the tool engineers already live in.
**Unlocks**:
- Bi-directional flow: ticket → session → documentation → ticket
- Auto-populate client name, ticket number from PSA
- Pull client history from PSA into session context
- Time entry sync (PSA time tracking from session duration)
**Effort**: High (multiple PSA APIs, OAuth flows, field mapping, each PSA is different)
**Risk**: PSA APIs are notoriously painful. ConnectWise alone could take weeks. Start with ONE (whichever Michael's MSP uses).
**Score**: 🔥 **Must do** — eliminates the biggest adoption barrier
---
### 3. Client Intelligence: "What happened last time?"
**What**: When starting a session, show the engineer a sidebar with: previous sessions for this client, known configurations, recurring issues, client-specific notes. Build a living client dossier from accumulated session data.
**Why 10x**: MSPs manage dozens of clients. Engineers often handle tickets for clients they haven't worked with before. Today they dig through PSA history or ask colleagues. ResolutionFlow already captures client_name on sessions — aggregating this into a client profile transforms it from a troubleshooting tool into a **client knowledge base**.
**Unlocks**:
- "This client had the same VPN issue 3 times in 6 months" — pattern detection
- Client-specific tree customizations (e.g., "Acme uses Cisco, not Fortinet")
- Handoff quality — new engineer sees full client troubleshooting history
- Churn risk signals for MSP account managers
**Effort**: Medium-High (client entity, aggregation, UI sidebar in session view)
**Risk**: Depends on consistent client naming (or PSA integration for canonical names)
**Score**: 🔥 **Must do** — obvious value, data already partially exists
---
### 4. AI Copilot: "What should I try next?"
**What**: An AI assistant within sessions that can: suggest next steps based on symptoms described in notes, generate PowerShell/CLI commands tailored to the situation, explain error messages, and recommend relevant KB articles or similar past sessions.
**Why 10x**: This makes junior engineers perform like seniors. MSPs constantly struggle with training — juniors escalate too quickly, seniors are bottlenecked. An AI copilot that understands the tree context + session notes + client history could dramatically reduce escalation rates and resolution times.
**Unlocks**:
- "Paste the error message" → AI suggests likely cause + next tree branch
- Dynamic command generation (fill in hostnames, IPs from session context)
- Natural language search across all trees ("how do I fix BSOD after update?")
- Auto-generate tree drafts from freeform troubleshooting notes
**Effort**: Very High (LLM integration, prompt engineering, context management, cost control)
**Risk**: Accuracy matters enormously — bad advice in IT troubleshooting can cause outages. Needs confidence indicators and human-in-the-loop.
**Score**: 👍 **Strong** — transformative but needs careful execution, do after intelligence loop
---
## Medium Opportunities
### 1. Step-Level Time Tracking + Resolution Outcomes
**What**: Automatically capture duration at each tree step (timestamp on entry/exit). Add a "Did this resolve the issue?" prompt at session end with outcome categories (resolved, escalated, workaround, unresolved). Surface this in session history and analytics.
**Why 10x**: This is the foundation for everything else. Without outcome tracking, you can't measure tree effectiveness. Without time tracking, you can't quantify ROI. This data answers: "Is ResolutionFlow actually saving us time?" — the question every MSP manager will ask before buying.
**Impact**: Enables ROI dashboards, tree optimization, SLA compliance tracking
**Effort**: Low-Medium (timestamps exist, just need step-level granularity + outcome modal)
**Score**: 🔥 **Must do** — foundational data, low effort, unlocks analytics
---
### 2. Tree Effectiveness Dashboard
**What**: A dashboard showing: most-used trees, average resolution time per tree, completion rate, escalation rate, most-common paths taken (heatmap on tree visualization), and trees with high custom-step insertion (signals missing content).
**Why 10x**: MSP managers have zero visibility into troubleshooting quality today. This dashboard sells the product to decision-makers (not just engineers). It answers: "Which trees need improvement?" and "Which engineers need training?"
**Impact**: Turns ResolutionFlow from an engineer tool into a management tool — expands buyer persona
**Effort**: Medium (aggregation queries, dashboard UI, tree heatmap visualization)
**Score**: 🔥 **Must do** — sells to managers, not just engineers
---
### 3. Tree Templates + Marketplace
**What**: Pre-built tree packs for common MSP scenarios (M365 admin, Azure AD, network troubleshooting, endpoint management). Allow MSPs to publish and share tree templates. Eventually: a marketplace where top MSPs sell their proven playbooks.
**Why 10x**: Building trees from scratch is the biggest adoption barrier after PSA integration. If an MSP can import a "Microsoft 365 Troubleshooting" pack on day one, time-to-value collapses from weeks to minutes. A marketplace creates network effects — more MSPs = more templates = more valuable for everyone.
**Impact**: Eliminates cold start, creates community, potential revenue stream
**Effort**: Medium (template export/import, curation, marketplace UI is later)
**Score**: 👍 **Strong** — import/export is quick win, full marketplace is later
---
### 4. Team Activity Feed + Collaboration
**What**: A team-wide activity feed showing: sessions started/completed, trees created/updated, custom steps shared, and the ability to comment on sessions ("Hey, next time try X instead"). Add @mentions and notifications.
**Why 10x**: MSPs are teams, not individuals. Knowledge sharing between engineers is where the real value compounds. Today a senior engineer's expertise lives in their head. Activity feeds make institutional knowledge visible and enable peer learning.
**Impact**: Transforms solo tool into team platform, increases daily engagement
**Effort**: Medium (activity model, feed UI, notifications)
**Score**: 👍 **Strong** — high engagement driver, moderate effort
---
### 5. Quick-Start from Clipboard
**What**: Engineer pastes a ticket description or error message → ResolutionFlow analyzes it and suggests the most relevant tree + starting branch. One click to begin a pre-contextualized session.
**Why 10x**: Eliminates the "which tree do I use?" friction. Engineers currently browse a library — with 50+ trees, this becomes a bottleneck. Clipboard analysis makes the tool feel intelligent and fast.
**Impact**: Reduces session start time from 30s of browsing to 3s of paste-and-go
**Effort**: Medium (text analysis, tree matching — could be keyword-based initially, AI later)
**Score**: 👍 **Strong** — significant UX improvement, can start simple
---
## Small Gems
### 1. Session Timer (Visible Clock)
**What**: A live timer in the session header showing elapsed time. Optionally, a "target time" per tree (e.g., "Password resets should take <5 min").
**Why powerful**: Engineers are often unaware of time spent. Visible timer creates gentle urgency, helps with time entries, and provides data for analytics. Trivial to build.
**Effort**: Low (frontend-only, a `Date.now() - startedAt` display)
**Score**: 🔥 **Must do**
### 2. Keyboard-First Navigation
**What**: Number keys (1-9) to select options, Enter to continue, Escape to go back, Tab to focus notes. Full keyboard-driven troubleshooting.
**Why powerful**: Engineers troubleshoot while on calls or remoted into machines. Mouse-dependent UI slows them down. Keyboard shortcuts make the tool feel professional and fast — power users will love it.
**Effort**: Low (event listeners, already have `useKeyboardShortcuts` hook)
**Score**: 🔥 **Must do**
### 3. "Repeat Last Session" Button
**What**: One-click to start a new session on the same tree you last used, pre-filled with the same client name.
**Why powerful**: MSP engineers often handle batches of similar tickets. "I'm doing password resets all morning." Eliminating re-selection saves minutes across dozens of sessions.
**Effort**: Very Low (store last session reference, pre-fill modal)
**Score**: 🔥 **Must do**
### 4. Session Draft Auto-Recovery
**What**: If browser crashes or closes mid-session, auto-recover from the last saved state on next visit. Show a "Resume interrupted session?" prompt.
**Why powerful**: Losing a 20-minute troubleshooting session to a browser crash is rage-inducing. Auto-recovery eliminates this anxiety and builds trust. Sessions already persist to DB — just need reconnection logic.
**Effort**: Low (check for incomplete sessions on login, offer resume)
**Score**: 🔥 **Must do**
### 5. Copy Individual Step to Clipboard
**What**: A copy icon on each step during session review that copies just that step's content (command, notes, outcome) to clipboard.
**Why powerful**: Engineers often need to share a specific step with a colleague or paste one command into a remote session. Currently must export entire session and find the relevant line.
**Effort**: Very Low (copy button per step in session detail view)
**Score**: 👍 **Strong**
### 6. "This Step is Wrong" Flag
**What**: A small flag/report button on each tree step during sessions. Flags aggregate for tree authors to review.
**Why powerful**: Creates a quality feedback loop without requiring formal reviews. Engineers won't write bug reports, but they'll click a flag button. Tree authors see which steps get flagged most.
**Effort**: Low (flag button, flag count on tree author view)
**Score**: 👍 **Strong**
### 7. Dark/Light Syntax Highlighting in Commands
**What**: PowerShell/CLI commands in tree steps get proper syntax highlighting (already have Monaco — could reuse its highlighting).
**Why powerful**: Engineers scan commands quickly when they're highlighted. Wall of monochrome text is harder to parse. Makes the product feel more premium and developer-native.
**Effort**: Low (use highlight.js or Monaco's tokenizer for inline code blocks)
**Score**: 🤔 **Maybe** — nice polish, not urgent
---
## Recommended Priority
### Do Now (Quick Wins — ship this week)
1. **Session Timer** — live elapsed time display in session header. Foundation for time analytics.
2. **Keyboard Navigation** — 1-9 for options, Enter/Escape, Tab to notes. Power user essential.
3. **Repeat Last Session** — one-click re-start with same tree/client. Batch workflow enabler.
4. **Session Draft Auto-Recovery** — resume interrupted sessions. Trust builder.
5. **Copy Step to Clipboard** — per-step copy button in session detail. Daily utility.
### Do Next (High Leverage — next 2-4 weeks)
1. **Step-Level Time Tracking + Outcome Capture** — foundational data for everything below
2. **"This Step is Wrong" Flag** — quality feedback loop for tree authors
3. **Tree Effectiveness Dashboard** — most-used trees, resolution rates, time metrics. Sells to managers.
4. **Quick-Start from Clipboard** — paste ticket text, get tree recommendation. Start with keyword matching.
### Explore (Strategic Bets — next 1-3 months)
1. **PSA Integration (ConnectWise first)** — one-click documentation push. Biggest adoption unlocker. Risk: API complexity. Start with the PSA Michael's team uses.
2. **Client Intelligence Sidebar** — past sessions for this client, recurring issues, client notes. Medium effort, enormous value for MSPs managing 30+ clients.
3. **Intelligence Loop / Analytics Engine** — mine session data for tree improvement signals, trending issues, engineer efficiency. THE compounding moat.
4. **Tree Templates + Import/Export** — pre-built MSP tree packs. Eliminates cold start for new teams.
### Backlog (Good but Not Now)
1. **AI Copilot** — powerful but premature. Need more session data and user trust first. Revisit after analytics foundation exists.
2. **Team Activity Feed** — valuable but not urgent until team sizes grow beyond 5-10.
3. **Marketplace** — needs critical mass of templates and users. Phase 4+.
4. **Syntax Highlighting** — polish, not priority.
---
## The Thesis
ResolutionFlow today is a **guided workflow tool**. That's valuable but replaceable — a good Notion template could approximate it.
The 10x version is a **troubleshooting intelligence platform**: every session makes the system smarter, every team member's knowledge becomes institutional, every client interaction builds a living dossier, and every metric proves ROI to the person signing the check.
The moat is the data flywheel:
```
More sessions → better analytics → smarter trees → faster resolutions → more adoption → more sessions
```
**The single most important near-term move**: add step-level time tracking + session outcomes. It's low effort, but without it, you can't prove value, optimize trees, or build analytics. Everything else depends on this data.
**The single most important strategic move**: PSA integration. It's the difference between "another tool to check" and "the tool that lives inside my workflow." MSP engineers live in ConnectWise/Autotask. Meet them there.
---
## Questions
### Answered (from codebase research)
- **Q**: Does the product capture enough data for analytics? **A**: Yes — session decisions, path taken, timestamps, client names, tree snapshots all exist. Missing: step-level timing and explicit outcomes.
- **Q**: Is multi-tenant ready? **A**: Yes — Account/Subscription/Team models exist with RBAC. SaaS foundation is solid.
- **Q**: What PSA does Michael's MSP use? **A**: Not specified in codebase. ConnectWise and Kaseya mentioned in Phase 4 roadmap.
### Blockers (need user input)
- **Q**: Which PSA does your MSP use? This determines integration priority.
- **Q**: How many trees do you realistically expect teams to maintain? (5? 20? 100?) — affects whether search/recommendation is urgent.
- **Q**: What's the current session volume? Enough to make analytics meaningful?
- **Q**: Would your manager pay for a dashboard showing team resolution metrics and ROI?
## Next Steps
- [ ] Ship quick wins (timer, keyboard nav, repeat session, auto-recovery)
- [ ] Add step-level timestamps to session decision records
- [ ] Add session outcome capture (resolved/escalated/unresolved)
- [ ] Determine PSA target for first integration
- [ ] Design tree effectiveness dashboard mockup
- [ ] Validate "paste ticket → suggest tree" with Michael's real ticket descriptions

View File

@@ -1,173 +0,0 @@
# Admin Panel: Invite Codes + User Management Enhancement
## Context
The admin panel has basic invite code CRUD and user listing, but lacks:
- **Plan assignment on invite codes** — all registrations get "free" plan
- **Email delivery** — admin must manually copy/send codes
- **Trial duration** — no time-limited plan access for beta testers
- **User detail page** — no way to view/manage a user's subscription, activity, or trial
This change enables the admin to create invite codes tied to specific plans (free/pro/team) with optional trial durations, send branded invite emails via Resend, and manage user subscriptions from a detailed user page.
---
## Phase 1: Database Migration (030)
**New file:** `backend/alembic/versions/030_enhance_invite_codes.py`
Add columns to `invite_codes`:
- `email` (String(255), nullable, indexed)
- `assigned_plan` (String(50), nullable, default `'free'`, CHECK `free/pro/team`)
- `trial_duration_days` (Integer, nullable)
- `email_sent_at` (DateTime(timezone=True), nullable)
**Update:** `backend/app/models/invite_code.py` — add fields + `has_trial` and `email_sent` properties
---
## Phase 2: Resend Email Integration
**New file:** `backend/app/core/email.py`
- `EmailService` class with `send_invite_email(to, code, plan, trial_days)`
- Graceful degradation: if `RESEND_API_KEY` not set, log warning, skip sending
- Email failure doesn't block invite creation (best-effort)
**New file:** `backend/app/templates/invite_email.html`
- Branded HTML email: monochrome design, ResolutionFlow logo, CTA button
- Shows invite code, plan name, trial duration if applicable, signup link
**Update:** `backend/app/core/config.py` — add `RESEND_API_KEY`, `FROM_EMAIL`, `email_enabled` property
**Update:** `backend/requirements.txt` — add `resend`
**Env vars:** `RESEND_API_KEY`, `FROM_EMAIL=ResolutionFlow <invites@resolutionflow.com>`
---
## Phase 3: Backend API Changes
### Invite code enhancements
**Update:** `backend/app/schemas/invite_code.py`
- `InviteCodeCreate`: add `email`, `assigned_plan`, `trial_duration_days`
- `InviteCodeResponse`: add new fields + computed `has_trial`, `email_sent`
**Update:** `backend/app/api/endpoints/invite.py`
- `create_invite_code`: accept new fields, send email if email provided, set `email_sent_at`, audit log
### Registration plan assignment
**Update:** `backend/app/api/endpoints/auth.py` (lines 178-183)
- When `invite_code_record` has `assigned_plan`/`trial_duration_days`, apply to new subscription
- Set `plan=invite_code_record.assigned_plan`, `status='trialing'` if trial, calculate `current_period_end`
### Subscription management endpoints
**Update:** `backend/app/api/endpoints/admin.py`
- `PUT /admin/users/{id}/subscription/plan` — change plan
- `PUT /admin/users/{id}/subscription/extend-trial` — add days to trial
- `GET /admin/users/{id}/detail` — enhanced user detail with account, subscription, sessions, audit logs, invite code used
**New file:** `backend/app/schemas/subscription.py``SubscriptionPlanUpdate`, `ExtendTrialRequest`, `SubscriptionResponse`
**New file:** `backend/app/schemas/user_detail.py``UserDetailResponse`, `SessionSummary`, `AuditLogSummary`, `AccountSummary`
### Trial expiry on login (lightweight)
**Update:** `backend/app/api/deps.py` — in `get_current_active_user`, check if subscription is trialing and expired → auto-downgrade to free
---
## Phase 4: Frontend Types & API Client
**Update:** `frontend/src/types/admin.ts`
- Enhanced `InviteCodeResponse` with email/plan/trial fields
- New: `UserDetail`, `SubscriptionDetail`, `SessionSummary`, `AuditLogSummary`, `AccountSummary`
**Update:** `frontend/src/api/admin.ts`
- Enhanced `createInviteCode` with new fields
- New: `getUserDetail`, `updateUserSubscriptionPlan`, `extendUserTrial`
---
## Phase 5: Frontend — Enhanced Invite Codes Page
**Update:** `frontend/src/pages/admin/InviteCodesPage.tsx`
Create form additions:
- Email input (optional, validated)
- Plan selector dropdown (Free / Pro / Team)
- Trial duration input (number of days, shown only if plan != free)
Table additions:
- "Recipient" column (email or "—")
- "Plan" column with badge
- "Trial" column (days or "—")
- "Email Sent" indicator
---
## Phase 6: Frontend — User Detail Page
**New file:** `frontend/src/pages/admin/UserDetailPage.tsx`
Sections:
1. **Header** — name, email, role badges, active status
2. **Account & Subscription card** — plan, status, trial end date, account display code
3. **Admin Actions card** — Change Role, Change Plan, Extend Trial, Activate/Deactivate (modal-based)
4. **Recent Sessions tab** — tree name, started, completed, outcome
5. **Audit Logs tab** — action, resource, timestamp, expandable details
6. **Invite Code card** — code used, plan assigned, who created it
**Update:** `frontend/src/router.tsx` — add route `admin/users/:userId`
**Update:** `frontend/src/pages/admin/UsersPage.tsx` — make user rows clickable → navigate to detail page
---
## Implementation Order
1. Migration 030 (invite code fields)
2. Model update (invite_code.py)
3. Resend integration (email.py, config.py, template, requirements.txt)
4. Backend schemas (invite_code, subscription, user_detail)
5. Backend API (invite.py, auth.py, admin.py, deps.py)
6. Backend tests
7. Frontend types + API client
8. Frontend invite codes page enhancement
9. Frontend user detail page
10. End-to-end testing
---
## Files to Create
- `backend/alembic/versions/030_enhance_invite_codes.py`
- `backend/app/core/email.py`
- `backend/app/templates/invite_email.html`
- `backend/app/schemas/subscription.py`
- `backend/app/schemas/user_detail.py`
- `frontend/src/pages/admin/UserDetailPage.tsx`
## Files to Modify
- `backend/app/models/invite_code.py`
- `backend/app/schemas/invite_code.py`
- `backend/app/api/endpoints/invite.py`
- `backend/app/api/endpoints/auth.py` (lines 178-183)
- `backend/app/api/endpoints/admin.py`
- `backend/app/api/deps.py`
- `backend/app/core/config.py`
- `backend/requirements.txt`
- `frontend/src/types/admin.ts`
- `frontend/src/api/admin.ts`
- `frontend/src/pages/admin/InviteCodesPage.tsx`
- `frontend/src/pages/admin/UsersPage.tsx`
- `frontend/src/router.tsx`
---
## Verification
1. **Backend tests:** Create invite with plan+trial → register with code → verify subscription has correct plan/status/period_end
2. **Email test:** Mock Resend, verify template renders, verify email_sent_at set on success
3. **Trial expiry:** Create expired trial → login → verify auto-downgrade to free
4. **Admin UI:** Create invite with email+plan+trial → verify email sent → register → verify in user detail page → change plan → extend trial
5. **Build:** `cd frontend && npm run build` passes
6. **Full test suite:** `cd backend && pytest --override-ini="addopts="` passes

View File

@@ -1,253 +0,0 @@
# Admin Panel: Invite Codes + User Management Enhancement
Date: 2026-02-12
Status: Proposed
## Summary
Enhance admin capabilities to:
1. Create invite codes tied to plans (`free`, `pro`, `team`) with optional trial durations.
2. Send invite emails via Resend (best-effort, non-blocking).
3. Apply invite-assigned plan/trial on registration.
4. Give admins a detailed user management view with subscription/session/audit context.
5. Support admin subscription actions (change plan, extend/start trial).
6. Auto-downgrade expired trials during authenticated access checks.
## Goals
- Remove manual invite-code sharing workflow.
- Support controlled beta onboarding with plan + trial at invite level.
- Enable operational admin workflows for account/subscription lifecycle.
- Keep backward compatibility where practical and avoid unsafe breaking changes.
## Non-Goals
- Stripe billing workflow redesign.
- Full historical pagination for user-detail sessions/audits in this iteration.
- Rework of account invite (`/accounts/me/invites`) flow.
## Key Decisions Locked
- Invite API path standardization: use `/invites` (frontend and backend aligned).
- User detail endpoint: enrich existing `GET /admin/users/{id}`.
- Invite `email` is advisory only (no strict email-match enforcement at registration).
- Invite plan/trial applies whenever a valid invite code is provided, even if `REQUIRE_INVITE_CODE=false`.
- Trial duration bounds: `1..90` days.
- Extend trial endpoint may convert non-trialing subscriptions to `trialing`.
- User detail payload includes recent summaries (latest 10 sessions + latest 10 audit logs) plus total counts.
## Scope by Phase
## Phase 1: Database Migration (`030`)
Create `backend/alembic/versions/030_enhance_invite_codes.py` (down revision `029`).
Add to `invite_codes`:
- `email`: `String(255)`, nullable, indexed.
- `assigned_plan`: `String(50)`, non-null, server default `'free'`.
- `trial_duration_days`: `Integer`, nullable.
- `email_sent_at`: `DateTime(timezone=True)`, nullable.
Constraints:
- `assigned_plan IN ('free','pro','team')`.
- `trial_duration_days IS NULL OR trial_duration_days BETWEEN 1 AND 90`.
- Optional consistency guard: `assigned_plan='free'` implies `trial_duration_days IS NULL`.
Update model `backend/app/models/invite_code.py`:
- Add mapped columns above.
- Add computed properties:
- `has_trial: bool` (`trial_duration_days is not None and > 0`)
- `email_sent: bool` (`email_sent_at is not None`)
## Phase 2: Resend Email Integration
Create `backend/app/core/email.py`:
- `EmailService.send_invite_email(to_email, code, plan, trial_days, signup_url) -> bool`.
- Returns `False` if `RESEND_API_KEY` missing.
- Catches provider failures and returns `False` (logs warning/error).
- Never blocks invite creation.
Create `backend/app/templates/invite_email.html`:
- Monochrome branded HTML.
- Invite code, plan, optional trial text, signup CTA button.
Update `backend/app/core/config.py`:
- `RESEND_API_KEY: Optional[str] = None`
- `FROM_EMAIL: str = "ResolutionFlow <invites@resolutionflow.com>"`
- `email_enabled` property.
Update `backend/requirements.txt`:
- Add `resend` package.
## Phase 3: Backend Schemas + Endpoints
### Invite code schemas
Update `backend/app/schemas/invite_code.py`:
- `InviteCodeCreate` adds:
- `email: Optional[EmailStr]`
- `assigned_plan: Literal['free','pro','team'] = 'free'`
- `trial_duration_days: Optional[int]` (1..90)
- `InviteCodeResponse` adds:
- `email`, `assigned_plan`, `trial_duration_days`, `email_sent_at`
- computed flags `has_trial`, `email_sent`.
### Invite endpoints
Update `backend/app/api/endpoints/invite.py`:
- `POST /invites` accepts new fields.
- Creates invite with plan/trial/email metadata.
- If email provided, attempts send:
- on success: set `email_sent_at`.
- on failure: invite still returns 201.
- Add audit log for invite creation with delivery result.
- Keep `GET /invites`, `DELETE /invites/{code}`, `GET /invites/validate/{code}` behavior compatible.
### Registration plan assignment
Update `backend/app/api/endpoints/auth.py`:
- If invite code is supplied and valid, load it and apply invite plan/trial regardless of `REQUIRE_INVITE_CODE`.
- For non-account-invite registrations:
- create subscription `plan=invite_code.assigned_plan` (fallback `free`).
- if `trial_duration_days` set:
- `status='trialing'`
- `current_period_start=now`
- `current_period_end=now + trial_duration_days`.
- else `status='active'`.
- Preserve account-invite join flow behavior.
- Mark invite as used post user creation.
### Admin subscription + detail endpoints
Update `backend/app/api/endpoints/admin.py`:
- Enrich `GET /admin/users/{id}` response:
- base user fields
- account summary
- subscription summary
- recent sessions (10) + total count
- recent audit logs (10) + total count
- invite code used summary
- Add:
- `PUT /admin/users/{id}/subscription/plan`
- `PUT /admin/users/{id}/subscription/extend-trial`
### Trial expiry check
Update `backend/app/api/deps.py`:
- In `get_current_active_user`, check account subscription.
- If `status='trialing'` and expired, auto-downgrade:
- `plan='free'`, `status='active'`
- clear/normalize trial period fields
- commit before returning user.
## Phase 4: Backend Schema Additions
Use existing file `backend/app/schemas/subscription.py` (do not duplicate):
- Add `SubscriptionPlanUpdate`.
- Add `ExtendTrialRequest`.
- Keep/extend `SubscriptionResponse` as needed.
Create `backend/app/schemas/user_detail.py`:
- `AccountSummary`
- `SessionSummary`
- `AuditLogSummary`
- `InviteCodeUsedSummary`
- `UserDetailResponse` (superset for enriched `/admin/users/{id}`).
## Phase 5: Frontend Types + API Client
Update `frontend/src/types/admin.ts`:
- Invite response fields for email/plan/trial/email-sent metadata.
- New detail types:
- `UserDetail`
- `SubscriptionDetail`
- `SessionSummary`
- `AuditLogSummary`
- `AccountSummary`.
Update `frontend/src/api/admin.ts`:
- Switch invite endpoints to `/invites`.
- Enhance `createInviteCode` payload.
- Add:
- `getUserDetail(userId)`
- `updateUserSubscriptionPlan(userId, plan)`
- `extendUserTrial(userId, days)`.
## Phase 6: Frontend Invite Codes Page
Update `frontend/src/pages/admin/InviteCodesPage.tsx`:
- Create form fields:
- optional email
- plan selector (Free/Pro/Team)
- trial days input when plan != free
- Table additions:
- recipient
- plan badge
- trial column
- email sent indicator
- Preserve existing create/copy/delete actions and status badges.
## Phase 7: Frontend User Detail Page
Create `frontend/src/pages/admin/UserDetailPage.tsx`:
- Header: name/email/role/active.
- Account & subscription card.
- Admin actions:
- change role
- change plan
- extend/start trial
- activate/deactivate
- Tabs:
- recent sessions
- audit logs
- Invite code card:
- code, assigned plan, creator.
Update `frontend/src/router.tsx`:
- Add route `admin/users/:userId`.
Update `frontend/src/pages/admin/UsersPage.tsx`:
- Make rows navigate to detail.
- Ensure action menu clicks do not trigger row navigation.
## API / Interface Changes
### Modified
- `POST /invites`
- new request fields: `email`, `assigned_plan`, `trial_duration_days`.
- `GET /invites`
- new response fields: `email`, `assigned_plan`, `trial_duration_days`, `email_sent_at`, `has_trial`, `email_sent`.
- `GET /admin/users/{id}`
- enriched response with account/subscription/recent activity details.
### Added
- `PUT /admin/users/{id}/subscription/plan`
- `PUT /admin/users/{id}/subscription/extend-trial`
## Test Plan
## Backend tests
1. Invite create with `assigned_plan + trial_duration_days` persists correctly.
2. Invite create with email:
- Resend success sets `email_sent_at`.
- Resend failure still returns 201 and does not set `email_sent_at`.
3. Registration with invite applies correct subscription plan/status/period fields.
4. Registration with optional invite (`REQUIRE_INVITE_CODE=false`) still applies plan/trial.
5. Expired trial auto-downgrades on authenticated request.
6. Admin plan update endpoint updates subscription + audit logs.
7. Admin extend-trial endpoint converts/extends correctly + audit logs.
8. Enriched `GET /admin/users/{id}` returns expected shape and list-size caps.
## Frontend verification
1. Create invite with email + plan + trial from admin UI.
2. Confirm invite table renders recipient/plan/trial/email-sent.
3. Open user detail from users table.
4. Change plan and extend trial from detail page.
5. Confirm updated values refresh in UI.
6. `npm run build` passes.
## Commands
- `cd backend && pytest --override-ini="addopts="`
- `cd frontend && npm run build`
## Risks and Mitigations
- Endpoint drift (`/invite-codes` vs `/invites`): update admin API client and validate all admin invite calls.
- Subscription side-effects in auth/deps: centralize trial-expiry logic and cover with tests.
- Payload growth for user detail: cap related arrays at 10 and include totals.
- Email provider outages: best-effort send with logging, no invite creation failure.
## Rollout
1. Deploy migration and backend changes.
2. Validate admin invite creation and registration path in staging.
3. Deploy frontend with new invite/user-detail UI.
4. Monitor audit logs and invite email delivery behavior post-release.
## Assumptions
- Existing admin access control (`require_admin`) remains unchanged.
- Plan limits for `free/pro/team` are already configured in `plan_limits`.
- No mandatory template engine addition is required for this email template rendering path.

View File

@@ -1,390 +0,0 @@
# Admin Panel: Invite Codes + User Management Enhancement
**Date:** 2026-02-12
**Status:** Proposed — Combined Plan
---
## Summary
Enhance admin capabilities to:
1. Create invite codes tied to plans (`free`, `pro`, `team`) with optional trial durations.
2. Send invite emails via Resend (best-effort, non-blocking).
3. Apply invite-assigned plan/trial on registration.
4. Give admins a detailed user management view with subscription, session, and audit context.
5. Support admin subscription actions (change plan, extend/start trial).
6. Auto-downgrade expired trials during authenticated access checks.
---
## Goals
- Remove manual invite-code sharing workflow.
- Support controlled beta onboarding with plan + trial at invite level.
- Enable operational admin workflows for account/subscription lifecycle.
- Keep backward compatibility where practical and avoid unsafe breaking changes.
---
## Non-Goals
- Stripe billing workflow redesign.
- Full historical pagination for user-detail sessions/audits in this iteration.
- Rework of account invite (`/accounts/me/invites`) flow.
---
## Key Decisions Locked
| Decision | Choice | Rationale |
|----------|--------|-----------|
| Invite API path | Standardize on `/invites` | Already in use (`router = APIRouter(prefix="/invites")`). Update CLAUDE.md which incorrectly references `/invite-codes`. |
| User detail endpoint | Enrich existing `GET /admin/users/{id}` | One endpoint, richer response. No reason for admin to get a "lite" version. |
| Invite email matching | Advisory only (no strict enforcement) | The invite code itself is the security gate. Email is for admin tracking. Strict matching creates friction during beta. |
| Invite plan/trial application | Applies whenever a valid invite code is provided, even if `REQUIRE_INVITE_CODE=false` | Ensures plan/trial always carries through regardless of registration policy. |
| Trial duration bounds | 190 days | 90 days covers any realistic beta period. Protects against typos. Admin can always extend after expiry. |
| Extend trial behavior | May convert non-trialing subscriptions to `trialing` | Admin should have maximum control. Covers scenarios like forgotten trial assignment or second chances. |
| User detail payload | Recent summaries (latest 10 sessions + 10 audit logs) + total counts | Balances useful at-a-glance admin view with response performance. Full history via future paginated endpoints. |
---
## Phase 1: Database Migration (030)
**New file:** `backend/alembic/versions/030_enhance_invite_codes.py` (down revision `029`)
Add columns to `invite_codes`:
- `email`: `String(255)`, nullable, indexed.
- `assigned_plan`: `String(50)`, non-null, server default `'free'`.
- `trial_duration_days`: `Integer`, nullable.
- `email_sent_at`: `DateTime(timezone=True)`, nullable.
Database constraints:
- `assigned_plan IN ('free', 'pro', 'team')`.
- `trial_duration_days IS NULL OR trial_duration_days BETWEEN 1 AND 90`.
- Consistency guard: `assigned_plan = 'free'` implies `trial_duration_days IS NULL`.
**Update:** `backend/app/models/invite_code.py`
- Add mapped columns for all new fields.
- Add computed properties:
- `has_trial: bool``trial_duration_days is not None and > 0`
- `email_sent: bool``email_sent_at is not None`
---
## Phase 2: Resend Email Integration
**New file:** `backend/app/core/email.py`
- `EmailService` class with `send_invite_email(to_email, code, plan, trial_days, signup_url) -> bool`.
- Returns `False` if `RESEND_API_KEY` not set (graceful degradation).
- Catches provider failures, returns `False`, logs warning/error.
- Never blocks invite creation (best-effort delivery).
**New file:** `backend/app/templates/invite_email.html`
- Branded HTML email: monochrome design, ResolutionFlow logo, CTA button.
- Shows invite code, plan name, trial duration if applicable, signup link.
**Update:** `backend/app/core/config.py`
- Add `RESEND_API_KEY: Optional[str] = None`
- Add `FROM_EMAIL: str = "ResolutionFlow <invites@resolutionflow.com>"`
- Add `email_enabled` computed property.
**Update:** `backend/requirements.txt` — add `resend` package.
**Env vars required:** `RESEND_API_KEY`, `FROM_EMAIL` (has default).
**Prerequisite:** DNS records (SPF, DKIM) must be configured in Resend for `resolutionflow.com` domain before production email delivery will work.
---
## Phase 3: Backend Schemas + Endpoints
### 3a. Invite Code Schemas
**Update:** `backend/app/schemas/invite_code.py`
`InviteCodeCreate` — add fields:
- `email: Optional[EmailStr]`
- `assigned_plan: Literal['free', 'pro', 'team'] = 'free'`
- `trial_duration_days: Optional[int]` (validated 190)
`InviteCodeResponse` — add fields:
- `email`, `assigned_plan`, `trial_duration_days`, `email_sent_at`
- Computed flags: `has_trial`, `email_sent`
### 3b. Invite Endpoints
**Update:** `backend/app/api/endpoints/invite.py`
- `POST /invites` — accept new fields (email, assigned_plan, trial_duration_days).
- Create invite with plan/trial/email metadata.
- If email provided, attempt send via EmailService.
- On send success: set `email_sent_at`.
- On send failure: invite still returns 201.
- Add audit log entry for invite creation with delivery result.
- Keep `GET /invites`, `DELETE /invites/{code}`, `GET /invites/validate/{code}` behavior compatible.
### 3c. Registration Plan Assignment
**Update:** `backend/app/api/endpoints/auth.py` (registration endpoint, around lines 178183)
- If invite code is supplied and valid, load it and apply invite plan/trial **regardless of `REQUIRE_INVITE_CODE` setting**.
- For non-account-invite registrations:
- Create subscription with `plan = invite_code.assigned_plan` (fallback `'free'`).
- If `trial_duration_days` is set:
- `status = 'trialing'`
- `current_period_start = now`
- `current_period_end = now + trial_duration_days`
- Else: `status = 'active'`.
- Preserve existing account-invite join flow behavior.
- Mark invite as used after user creation.
### 3d. Admin Subscription + User Detail Endpoints
**Update:** `backend/app/api/endpoints/admin.py`
Enrich `GET /admin/users/{id}` response to include:
- Base user fields (name, email, role, active status).
- Account summary (account name, display code).
- Subscription summary (plan, status, trial end date).
- Recent sessions: latest 10 + total count.
- Recent audit logs: latest 10 + total count.
- Invite code used summary (code, assigned plan, who created it).
Add new endpoints:
- `PUT /admin/users/{id}/subscription/plan` — change user's plan.
- `PUT /admin/users/{id}/subscription/extend-trial` — add days to trial, or convert to trialing if not already.
Both endpoints should create audit log entries.
### 3e. Trial Expiry Check
**Update:** `backend/app/api/deps.py` — in `get_current_active_user`
- Check account subscription status.
- If `status = 'trialing'` and `current_period_end < now`:
- Set `plan = 'free'`, `status = 'active'`.
- Clear/normalize trial period fields.
- Commit before returning user.
**Note:** This is a lightweight login-time check. Users with active JWT sessions will retain access until token refresh. Acceptable for beta; revisit if stricter enforcement needed later.
---
## Phase 4: Backend Schema Additions
**Check first:** Verify whether `backend/app/schemas/subscription.py` already exists. If it does, extend it. If not, create it.
Schemas needed in `backend/app/schemas/subscription.py`:
- `SubscriptionPlanUpdate` — for plan change requests.
- `ExtendTrialRequest` — for trial extension requests.
- `SubscriptionResponse` — for subscription state in responses.
**New file:** `backend/app/schemas/user_detail.py`
- `AccountSummary`
- `SessionSummary`
- `AuditLogSummary`
- `InviteCodeUsedSummary`
- `UserDetailResponse` (superset response for enriched `/admin/users/{id}`)
---
## Phase 5: Frontend Types + API Client
**Update:** `frontend/src/types/admin.ts`
- Enhanced `InviteCodeResponse` with email, plan, trial, email-sent fields.
- New types: `UserDetail`, `SubscriptionDetail`, `SessionSummary`, `AuditLogSummary`, `AccountSummary`.
**Update:** `frontend/src/api/admin.ts`
- Ensure invite endpoints target `/invites` (not `/invite-codes`).
- Enhance `createInviteCode` payload with new fields.
- Add: `getUserDetail(userId)`, `updateUserSubscriptionPlan(userId, plan)`, `extendUserTrial(userId, days)`.
---
## Phase 6: Frontend — Enhanced Invite Codes Page
**Update:** `frontend/src/pages/admin/InviteCodesPage.tsx`
Create form additions:
- Email input (optional, validated).
- Plan selector dropdown (Free / Pro / Team).
- Trial duration input (number of days, shown only when plan ≠ free).
Table additions:
- "Recipient" column (email or "—").
- "Plan" column with badge.
- "Trial" column (days or "—").
- "Email Sent" indicator.
Preserve existing create/copy/delete actions and status badges.
---
## Phase 7: Frontend — User Detail Page
**New file:** `frontend/src/pages/admin/UserDetailPage.tsx`
Sections:
- **Header** — name, email, role badges, active status.
- **Account & Subscription card** — plan, status, trial end date, account display code.
- **Admin Actions card** — Change Role, Change Plan, Extend/Start Trial, Activate/Deactivate (modal-based).
- **Recent Sessions tab** — tree name, started, completed, outcome.
- **Audit Logs tab** — action, resource, timestamp, expandable details.
- **Invite Code card** — code used, plan assigned, who created it.
**Update:** `frontend/src/router.tsx` — add route `admin/users/:userId`.
**Update:** `frontend/src/pages/admin/UsersPage.tsx` — make user rows clickable to navigate to detail page. Ensure action menu clicks (dropdowns, buttons) don't trigger row navigation.
---
## File Inventory
### Files to Create
| File | Phase |
|------|-------|
| `backend/alembic/versions/030_enhance_invite_codes.py` | 1 |
| `backend/app/core/email.py` | 2 |
| `backend/app/templates/invite_email.html` | 2 |
| `backend/app/schemas/subscription.py` (verify doesn't exist first) | 4 |
| `backend/app/schemas/user_detail.py` | 4 |
| `frontend/src/pages/admin/UserDetailPage.tsx` | 7 |
### Files to Modify
| File | Phase | What Changes |
|------|-------|-------------|
| `backend/app/models/invite_code.py` | 1 | Add new columns + computed properties |
| `backend/app/core/config.py` | 2 | Add RESEND_API_KEY, FROM_EMAIL, email_enabled |
| `backend/requirements.txt` | 2 | Add resend package |
| `backend/app/schemas/invite_code.py` | 3a | Add email, plan, trial fields to create/response |
| `backend/app/api/endpoints/invite.py` | 3b | Accept new fields, send email, audit log |
| `backend/app/api/endpoints/auth.py` | 3c | Apply invite plan/trial on registration (lines ~178183) |
| `backend/app/api/endpoints/admin.py` | 3d | Enrich user detail, add subscription endpoints |
| `backend/app/api/deps.py` | 3e | Trial expiry check in get_current_active_user |
| `frontend/src/types/admin.ts` | 5 | Enhanced invite + new detail types |
| `frontend/src/api/admin.ts` | 5 | New API functions, fix invite path |
| `frontend/src/pages/admin/InviteCodesPage.tsx` | 6 | Form + table enhancements |
| `frontend/src/pages/admin/UsersPage.tsx` | 7 | Clickable rows → detail page |
| `frontend/src/router.tsx` | 7 | Add user detail route |
### Also Update (Housekeeping)
| File | What Changes |
|------|-------------|
| `CLAUDE.md` | Fix invite codes endpoint reference from `/invite-codes` to `/invites` |
---
## API / Interface Changes
### Modified Endpoints
- `POST /invites` — new request fields: `email`, `assigned_plan`, `trial_duration_days`.
- `GET /invites` — new response fields: `email`, `assigned_plan`, `trial_duration_days`, `email_sent_at`, `has_trial`, `email_sent`.
- `GET /admin/users/{id}` — enriched response with account/subscription/recent activity details.
### New Endpoints
- `PUT /admin/users/{id}/subscription/plan`
- `PUT /admin/users/{id}/subscription/extend-trial`
---
## Implementation Order
1. Migration 030 (invite code fields)
2. Model update (invite_code.py)
3. Resend integration (email.py, config.py, template, requirements.txt)
4. Backend schemas (invite_code, subscription, user_detail)
5. Backend API (invite.py, auth.py, admin.py, deps.py)
6. Backend tests
7. Frontend types + API client
8. Frontend invite codes page enhancement
9. Frontend user detail page
10. End-to-end testing
---
## Test Plan
### Backend Tests
1. Invite create with `assigned_plan` + `trial_duration_days` persists correctly.
2. Invite create with email — Resend success sets `email_sent_at`.
3. Invite create with email — Resend failure still returns 201, does not set `email_sent_at`.
4. Registration with invite applies correct subscription plan/status/period fields.
5. Registration with optional invite (`REQUIRE_INVITE_CODE=false`) still applies plan/trial when code provided.
6. Expired trial auto-downgrades on authenticated request.
7. Admin plan update endpoint updates subscription + creates audit log.
8. Admin extend-trial endpoint converts/extends correctly + creates audit log.
9. Enriched `GET /admin/users/{id}` returns expected shape and list-size caps (10 sessions, 10 audit logs).
10. Trial duration validation rejects values outside 190 range.
11. Free plan invite rejects trial_duration_days (consistency guard).
### Frontend Verification
1. Create invite with email + plan + trial from admin UI.
2. Confirm invite table renders recipient/plan/trial/email-sent columns.
3. Open user detail from users table (click row).
4. Change plan and extend trial from detail page.
5. Confirm updated values refresh in UI.
6. `cd frontend && npm run build` passes.
### Commands
```
cd backend && pytest --override-ini="addopts="
cd frontend && npm run build
```
---
## Risks and Mitigations
| Risk | Mitigation |
|------|-----------|
| Endpoint drift (`/invite-codes` vs `/invites`) | Update CLAUDE.md and admin API client. Verify all admin invite calls use `/invites`. |
| Subscription side-effects in auth/deps | Centralize trial-expiry logic. Cover with targeted tests. |
| Payload growth for user detail | Cap related arrays at 10 items, include total counts. |
| Email provider outages | Best-effort send with logging. Invite creation never fails due to email. |
| DNS not configured for Resend | Document as prerequisite. Email gracefully degrades when API key missing. |
| `subscription.py` may already exist | Verify before creating. Extend if present, create if not. |
| JWT session outlives trial expiry | Acceptable for beta. Document as known limitation. |
---
## Rollout
1. Deploy migration and backend changes.
2. Validate admin invite creation and registration path in staging.
3. Deploy frontend with new invite/user-detail UI.
4. Monitor audit logs and invite email delivery behavior post-release.
---
## Assumptions
- Existing admin access control (`require_admin`) remains unchanged.
- Plan limits for `free/pro/team` are already configured in `plan_limits`.
- No mandatory template engine addition is required for email template rendering.
- Alembic `env.py` already imports `InviteCode` model (per LESSONS-LEARNED.md).

View File

@@ -1,239 +0,0 @@
# Issue #57: Command Output Capture — Implementation Plan
## Overview
Engineers run commands during troubleshooting sessions but the output is lost — exports say "ran this command" but not what it returned. This feature adds a "Paste Output" textarea on action nodes and custom action steps so command output is captured in session data and included in all exports and session review.
**Scope:** Built-in action nodes AND custom action steps.
**Migration:** None required — `decisions` is already a JSONB array with flexible dict entries.
---
## Public Interfaces / Type Changes
- **Backend:** Add `command_output: Optional[str] = Field(None, max_length=10000)` to `DecisionRecord` in `session.py`
- **Frontend:** Add `command_output?: string | null` to `DecisionRecord` type in `session.ts`
- **API:** No endpoint changes — `PUT /api/v1/sessions/{id}` continues to accept full decisions array; now includes optional `command_output`
- **Validation:** Backend enforces 10,000 character hard limit (returns 422 on overflow); frontend shows live character count
---
## Files to Modify
### Backend (3 files)
| File | Change |
|------|--------|
| `backend/app/schemas/session.py` | Add `command_output` field to `DecisionRecord` |
| `backend/app/services/export_service.py` | Render `command_output` in all 4 export formats with command context |
| `backend/app/core/session_to_tree.py` | Include command output when converting session decisions to forked tree nodes |
### Frontend (3 files)
| File | Change |
|------|--------|
| `frontend/src/types/session.ts` | Add `command_output` to `DecisionRecord` type |
| `frontend/src/pages/TreeNavigationPage.tsx` | Add capture UI for both built-in action nodes and custom action steps |
| `frontend/src/pages/SessionDetailPage.tsx` | Render `command_output` in decision review and clipboard copy |
---
## Implementation Steps
### Step 1: Backend Schema
**File:** `backend/app/schemas/session.py`
- Add `command_output: Optional[str] = Field(None, max_length=10000)` to `DecisionRecord`
- The `max_length=10000` provides backend validation — requests exceeding this return 422
### Step 2: Frontend Type
**File:** `frontend/src/types/session.ts`
- Add `command_output?: string | null` to the `DecisionRecord` type
- This ensures the field is not dropped by TypeScript typing during round-trips
### Step 3: TreeNavigationPage — Capture UI for Built-in Action Nodes
**File:** `frontend/src/pages/TreeNavigationPage.tsx`
**State:**
- Add `const [commandOutput, setCommandOutput] = useState('')` (same pattern as existing `notes` state)
- Clear `commandOutput` when node changes (same place `notes` is cleared)
- When revisiting a step, preload existing `command_output` from the decision record if present
**UI — on action nodes with `currentNode.commands?.length > 0`:**
- Render a collapsible "Paste Output (Optional)" section below the commands display
- Inside: a textarea with:
- Placeholder: `"Paste command output here..."`
- Monospace font (`font-mono`), consistent with command code block styling
- `bg-white/10` background styling to match existing design
- Live character count display: `"{count} / 10,000"` shown below the textarea
- Max length enforced on the frontend at 10,000 characters
- Use a `Terminal` icon from `lucide-react` for the section label
**Persistence — in `handleContinue()`:**
- Add `command_output: commandOutput.trim() || null` to the decision record pushed to the session
- Empty or whitespace-only input is normalized to `null` (treated as not provided)
### Step 4: TreeNavigationPage — Capture UI for Custom Action Steps
**File:** `frontend/src/pages/TreeNavigationPage.tsx`
Custom action steps create their decision record at insertion time, which is different from built-in action nodes. The output capture UI and behavior should be the same as Step 3, but persistence requires updating the existing decision rather than creating a new one.
**UI:**
- Same collapsible "Paste Output (Optional)" section as built-in action nodes
- Available when a custom action step has commands defined
**Persistence:**
- Before `handleContinueToDescendant` or `handleCustomBranchComplete` is called, update the current custom step's decision record with `command_output: commandOutput.trim() || null`
- Persist the updated decisions array to the backend before navigation/completion transitions
- This is a wrapper flow around the existing custom step logic — not a replacement of it
### Step 5: Export Service — All 4 Formats
**File:** `backend/app/services/export_service.py`
**Helpers to add:**
- A helper to safely extract and normalize `command_output` from a decision dict (strip whitespace, return `None` if empty)
- A helper to resolve the commands associated with a step for context display:
1. First look up the tree snapshot action node by `node_id`
2. Fallback to custom step metadata by `node_id`
3. Fallback to no command list (just show the output)
**Export rendering per format** (all guarded by `if command_output := decision.get("command_output")`):
**Markdown (`_generate_markdown_export`):**
```
**Commands Run:** `ping 8.8.8.8`, `tracert 8.8.8.8`
**Output:**
```
{output}
```
```
**Text (`_generate_text_export`):**
```
Commands Run: ping 8.8.8.8, tracert 8.8.8.8
Output:
{output with each line indented}
```
**HTML (`_generate_html_export`):**
```html
<p><strong>Commands Run:</strong> <code>ping 8.8.8.8</code>, <code>tracert 8.8.8.8</code></p>
<pre><code>{html.escape(output)}</code></pre>
```
**PSA (`_generate_psa_export`):**
```
Commands: ping 8.8.8.8, tracert 8.8.8.8
Output:
{output with each line indented}
```
### Step 6: SessionDetailPage — Review Display
**File:** `frontend/src/pages/SessionDetailPage.tsx`
**Decision review:**
- After the `action_performed` rendering for each decision, check for `command_output`
- If present, render in a `<pre>` block with monospace styling and preserved whitespace
- Label: "Command Output" with consistent styling
**Clipboard copy (`copyTicketNotes`):**
- After the action performed line, add:
```
Output:
{decision.command_output}
```
- Only include if `command_output` is present and non-empty
### Step 7: Session-to-Tree Conversion
**File:** `backend/app/core/session_to_tree.py`
- When building node descriptions from decisions, check for `command_output`
- If present, append the output text to the node description so forked trees retain the captured output
- Format: include a "Command Output:" label followed by the output text
---
## Edge Cases & Failure Modes
| Scenario | Behavior |
|----------|----------|
| Existing sessions without `command_output` | Render normally with no errors — field is optional |
| Output exceeds 10,000 characters | Frontend prevents input beyond limit; backend returns 422 if somehow exceeded |
| Empty or whitespace-only input | Normalized to `null` — treated as not provided |
| Multiline output, JSON, special characters | Preserved as-is; HTML export escapes all content |
| Steps without commands | Output can still be stored; export shows output even without command context |
| Multi-command action nodes | One shared output field per step (not per command) |
| Revisiting a completed step | Preloads the previously captured output |
---
## Test Cases
### Backend API Tests (`test_sessions.py`)
1. Update a session with `command_output` in a decision record → verify it round-trips correctly on GET
2. Submit `command_output` exceeding 10,000 characters → verify 422 response
3. Submit empty string and whitespace-only `command_output` → verify stored as `null`
### Export Tests
4. Markdown export includes command context and fenced code block for output
5. Text export includes output block with preserved line breaks
6. HTML export includes escaped `<pre><code>` block
7. PSA export includes compact command context and indented output
8. Multi-command action node exports with single shared output block
9. Export of session without any `command_output` renders cleanly (no errors, no empty blocks)
### Custom Action Step Tests
10. Insert custom action step with commands → capture output → continue → verify output stored in decision
11. Custom step output appears in all export formats
### Frontend Behavior Tests
12. Action node with commands shows the "Paste Output" section (collapsed by default)
13. Custom action step with commands shows the "Paste Output" section
14. Action node WITHOUT commands does NOT show the "Paste Output" section
15. Character count updates live as user types
16. Revisiting a step preloads previously captured output
17. Session detail page renders output block with monospace formatting
18. "Copy to clipboard" includes command output when present
---
## Verification Checklist (Manual)
1. `cd frontend && npm run build` — confirm no TypeScript errors
2. Start a session on a tree with action nodes that have commands:
- Paste output into the textarea
- Click Continue
- Verify output persists in the session data
3. Start a session and add a custom action step with commands:
- Paste output
- Continue to next step
- Verify output persists
4. Complete a session → check SessionDetailPage shows the command output with proper formatting
5. Export in all 4 formats → verify output appears correctly formatted in each
6. Use "Copy to clipboard" on a step with output → verify output is included
7. Run a session on a tree WITHOUT commands on action nodes → verify no output section appears
8. Test with existing sessions that have no `command_output` → verify they render and export without errors
9. Test pasting large output (near 10,000 chars) → verify character count and limit work
10. Test pasting multiline output with special characters → verify preservation in review and exports
---
## Assumptions
- One shared output field per step, not per individual command
- Maximum stored output is 10,000 characters
- v1 does not include syntax highlighting or image paste
- No feature flag gating — ships directly
- Collapsed-by-default UI keeps the interface clean for steps where output isn't needed

View File

@@ -1,74 +0,0 @@
# Resend Invite Codes — Design
**Date**: 2026-02-12
## Summary
Add a "Resend" capability to both invite systems (platform invite codes and account invites). Resending revokes the old code and generates a fresh one, then emails it to the recipient.
## Backend
### Platform Invite Codes
**New endpoint**: `POST /api/v1/invites/{code}/resend` (admin-only)
1. Look up existing invite by code
2. Reject if already used (409 Conflict)
3. Delete the old invite
4. Create a new invite with the same properties (email, plan, trial_days, note, expiration recalculated from now)
5. Send email via `EmailService.send_invite_email()`
6. Log audit event
7. Return the new invite
### Account Invites
**New endpoint**: `POST /api/v1/accounts/me/invites/{invite_id}/resend` (owner-only)
1. Look up existing invite by ID
2. Reject if already used (409 Conflict)
3. Delete the old invite
4. Create a new invite with the same email, role, and fresh expiration
5. Send email via new `EmailService.send_account_invite_email()`
6. Log audit event
7. Return the new invite
### New Email Method: `send_account_invite_email()`
Added to `EmailService` in `backend/app/core/email.py`.
- **Parameters**: `to_email`, `code`, `account_name`, `role`, `signup_url`
- **Subject**: "You've been invited to join [Account Name] on ResolutionFlow"
- **Body**: Same dark monochrome HTML template as platform invites, with:
- "You've been invited to join **[Account Name]** as an **Engineer/Viewer**"
- Prominent invite code display (same style)
- No plan/trial section
- "Create Your Account" CTA button
- **Returns** `bool` — best-effort, never raises
## Frontend
### Platform Invite Codes (`InviteCodesPage.tsx`)
- Resend button in actions column (next to copy/delete)
- Only visible for unused, non-expired codes with an email address
- Calls `POST /api/v1/invites/{code}/resend`
- Shows success toast with new code, refreshes list
- Loading state on button during API call
### Account Invites (`AccountSettingsPage.tsx`)
- Resend button next to each pending invite
- Only visible for unused, non-expired invites
- Calls `POST /api/v1/accounts/me/invites/{invite_id}/resend`
- Shows success toast, refreshes list
- Loading state on button during API call
## Files to Modify
- `backend/app/core/email.py` — add `send_account_invite_email()` + HTML template
- `backend/app/api/endpoints/invite.py` — add resend endpoint for platform codes
- `backend/app/api/endpoints/accounts.py` — add resend endpoint for account invites
- `frontend/src/pages/admin/InviteCodesPage.tsx` — add resend button
- `frontend/src/pages/AccountSettingsPage.tsx` — add resend button
- `frontend/src/api/admin.ts` — add resend API call
- `frontend/src/api/accounts.ts` — add resend API call

View File

@@ -1,450 +0,0 @@
# ResolutionFlow: User Management Plan Comparison & Merged Plan
## Part 1: Which Plan Was Better?
**Plan 1 (Admin User Lifecycle and Password Reset Expansion)** is the stronger initial plan overall.
It reads like a complete technical specification — the kind of document you'd hand to a developer and say "build this." It covers every API contract, every schema field, every security rule, and every edge case in a single cohesive document. It also gets several architectural decisions right that Plan 2 either misses or handles less safely.
That said, Plan 2 has real strengths that Plan 1 lacks, particularly around **implementation sequencing** and **developer-friendliness**. More on that below.
---
## Part 2: Side-by-Side Comparison
### Architecture & Data Model
| Aspect | Plan 1 | Plan 2 | Verdict |
|--------|--------|--------|---------|
| **Archive columns** | `is_archived`, `archived_at` on users table | `deleted_at`, `deleted_by` on users table (mirrors Tree model) | **Plan 2 is better.** Using `deleted_at`/`deleted_by` follows the soft-delete pattern already established in your Tree model. Consistency across models matters for maintainability. `deleted_by` also provides audit trail at the data level. |
| **must_change_password** | Included in single migration with archive fields | Gets its own dedicated migration (031) | **Plan 2 is better.** Smaller, focused migrations are safer and easier to debug. One concern per migration is best practice. |
| **Password reset tokens** | Dedicated `password_reset_tokens` DB table with hashed token, single-use enforcement | Stateless JWT with `type: "password_reset"` — no DB table | **Plan 1 is better.** A DB-backed token table enables true single-use enforcement and allows admins to revoke outstanding reset tokens. Stateless JWTs can't be invalidated once issued. For a commercial SaaS product, this is the right call. |
| **Temp password strength** | 16 chars, upper/lower/digit/symbol, excludes ambiguous chars | 12+ chars, 1 upper + 1 lower + 1 digit + `token_urlsafe` fill | **Plan 1 is better.** Longer, more complex, and excluding ambiguous characters (like `0/O`, `1/l`) is a better UX for someone reading a temp password off a screen or phone. |
| **Reset token TTL** | 30 minutes | 1 hour | **Plan 1 is better for security.** 30 minutes is standard for password reset links. 1 hour is generous. |
### Security
| Aspect | Plan 1 | Plan 2 | Verdict |
|--------|--------|--------|---------|
| **must_change_password enforcement** | Hard lock — blocks ALL authenticated requests except `/auth/password/change`, `/auth/logout`, `/auth/me` | Frontend redirect only (ProtectedRoute sends to /change-password) | **Plan 1 is significantly better.** Frontend-only enforcement is a security gap. Any API call from Postman, curl, or a script would bypass it entirely. Backend middleware enforcement is essential for a commercial product. |
| **Session invalidation** | Explicit policy: revoke all refresh tokens on any password change/reset | Mentions revoking refresh tokens but less systematic | **Plan 1 is better.** Having this as a named "policy" ensures it's applied consistently everywhere. |
| **Self-protection rules** | Not explicitly mentioned | Admin can't archive/delete themselves; hard delete refuses other super admins | **Plan 2 is better.** These are critical guardrails that Plan 1 assumes but doesn't spell out. |
| **Anti-enumeration** | Generic success response on forgot endpoint | Same, plus explicitly calls out "anti-enumeration" as a design goal | **Tie.** Both handle this correctly. |
### API Design
| Aspect | Plan 1 | Plan 2 | Verdict |
|--------|--------|--------|---------|
| **Admin user creation** | Supports two modes: `existing` account (join by display code) and `personal` (creates new account). Includes `send_email` toggle. | Single mode: requires `account_id` (UUID), no personal account creation. | **Plan 1 is better.** Supporting both modes matches your multi-tenant architecture. Creating a user who needs their own personal account is a real use case. Also, using `account_display_code` instead of raw UUID is much more admin-friendly. |
| **Hard delete** | Two-step: precheck endpoint (GET) returns blocker counts, then separate DELETE. Requires archived state first. | Single DELETE endpoint with optional `?cascade=true/false` parameter. | **Plan 1 is better.** The two-step precheck approach is safer — it prevents accidental data loss and gives the admin clear information about what's blocking the delete. A cascade flag on a DELETE endpoint is dangerous for a production SaaS platform. |
| **Hard delete dependency checking** | Exhaustive list of every FK reference that would block deletion | Not specified — just "removes sessions, trees, folder assignments" with cascade | **Plan 1 is much better.** Plan 2's cascade approach would silently destroy audit logs, trees, sessions, and other critical data. Plan 1's approach of blocking when dependencies exist and returning structured blocker counts is the enterprise-grade pattern. |
| **Admin reset modes** | Two modes: `email_link` (sends reset email) and `temp_password` (generates and returns temp) | Single mode: always sends reset email | **Plan 1 is better.** Having both options covers the real-world scenario where an admin is on the phone with a user and needs to give them a temp password immediately vs. sending an email for a less urgent reset. |
| **Verify reset token endpoint** | Not included (token is validated during the reset itself) | `POST /auth/verify-reset-token` — validates JWT, returns `{valid, email}` | **Plan 2 is better.** This lets the frontend verify the token is still valid before showing the "new password" form, providing a better UX. Without it, the user fills out the form only to learn the token expired on submit. |
### Frontend
| Aspect | Plan 1 | Plan 2 | Verdict |
|--------|--------|--------|---------|
| **Implementation detail** | Lists components and behaviors at a high level | Specifies exact file paths, store changes, router additions | **Plan 2 is better.** When you're handing this to Claude Code, specific file paths and component names eliminate ambiguity. |
| **Force change UX** | Dedicated `/force-password-change` route | `/change-password` route (dual-purpose: forced + voluntary) | **Plan 2 is better.** Using one route for both forced and voluntary password changes is simpler. The component can check `must_change_password` to adjust its messaging. |
| **Quick Invite** | Not included | Phase 5: "Invite User" button on UsersPage wrapping existing invite logic | **Plan 2 adds value.** This is a nice quality-of-life feature that leverages existing invite infrastructure. |
| **Account Settings integration** | Mentioned but not detailed | Detailed: ChangePasswordPage with current + new + confirm fields | **Plan 2 is better** for implementation clarity. |
### Structure & Implementation Approach
| Aspect | Plan 1 | Plan 2 | Verdict |
|--------|--------|--------|---------|
| **Document structure** | Single flat specification — everything in one document | Phased approach (5 phases) with clear build order | **Plan 2 is better.** Phased delivery means you can ship and test `must_change_password` + change password before tackling admin creation, which reduces risk and lets you validate each piece. |
| **Completeness** | Extremely thorough — covers every edge case, schema, audit event | Covers the main paths well but less edge-case detail | **Plan 1 is better** for completeness. |
| **Audit logging** | Comprehensive list of all new audit event types with naming convention | Mentions audit logging per feature but doesn't centralize the event taxonomy | **Plan 1 is better.** Having all audit events listed in one place ensures nothing is missed. |
| **Test plan** | Detailed acceptance criteria for both backend and frontend | Basic verification checklist + manual test scenarios | **Plan 1 is better** for backend testing. **Plan 2 is better** for manual test flows (the step-by-step scenarios are more practical). |
| **Key files list** | Not included | Explicit list of every file to create or modify | **Plan 2 is better.** This is invaluable for implementation planning and PR scoping. |
---
## Part 3: The Merged Plan
What follows takes the best elements from both plans and resolves the conflicts between them.
---
# User Management Enhancement — Merged Implementation Plan
## Overview
Implement admin user creation with temporary password, archive/restore and dependency-gated hard delete, self-service password reset, admin-triggered reset, and in-session password change. Built on existing Resend email service, JWT infrastructure, audit logging, and rate limiting.
---
## Phase 1: Foundation — must_change_password + Change Password
This phase ships independently and unlocks all subsequent phases.
### Migration 031: `add_must_change_password_to_users.py`
Add to `users` table:
- `must_change_password`: Boolean, default=False, server_default='false', nullable=False
### Backend Changes
**Model** (`backend/app/models/user.py`):
- Add `must_change_password` mapped column
**Schemas**:
- `UserResponse` (`backend/app/schemas/user.py`): add `must_change_password: bool = False`
- `Token` (`backend/app/schemas/token.py`): add `must_change_password: bool = False`
- New `ChangePasswordRequest` in `backend/app/schemas/auth_password.py`: `current_password: str`, `new_password: str` with password complexity validator
**Login endpoints** (`backend/app/api/endpoints/auth.py`):
- Include `must_change_password` in Token response after login
**New endpoint** `POST /api/v1/auth/password/change` in auth.py:
- Dependency: `get_current_active_user`
- Validate current password, reject if new password matches current
- Hash new password, set `must_change_password=False`
- Revoke all refresh tokens for user
- Audit log: `auth.password_change`
**Backend enforcement middleware** (critical — not just frontend):
- Add middleware or dependency that checks `must_change_password` on the current user
- If `True`, block all authenticated requests EXCEPT allowlisted routes: `/auth/password/change`, `/auth/logout`, `/auth/me`
- Return `403` with body `{"detail": "password_change_required"}` for blocked requests
### Frontend Changes
**New page**: `ChangePasswordPage.tsx`
- Current password + new password + confirm password form
- Dual-purpose: handles both forced change (shows warning banner, hides nav) and voluntary change from account settings
- On success: clears auth state and redirects to login
**Auth store** (`store/authStore.ts`):
- Store `must_change_password` from login/user response
**ProtectedRoute**:
- Check `must_change_password` AFTER the auth check but BEFORE rendering children
- If `must_change_password === true` AND current path is NOT `/change-password`, redirect to `/change-password`
- `/change-password` is exempted from the redirect so the user can actually change their password
**Router** (`router.tsx`):
- Add `/change-password` as protected route (requires auth, but exempt from must_change_password redirect)
**AccountSettingsPage.tsx**:
- Add "Change Password" section linking to or embedding the change password form
### Key Files
- `backend/alembic/versions/031_add_must_change_password_to_users.py`
- `backend/app/models/user.py`
- `backend/app/schemas/user.py`, `token.py`, `auth_password.py` (new)
- `backend/app/api/endpoints/auth.py`
- `frontend/src/pages/ChangePasswordPage.tsx` (new)
- `frontend/src/store/authStore.ts`
- `frontend/src/router.tsx`
- `frontend/src/pages/AccountSettingsPage.tsx`
---
## Phase 2: Admin User Creation (M365-Style)
### Backend Changes
**Temp password generator** (`backend/app/core/security.py`):
- Generate 16-character password: upper + lower + digit + symbol, excluding ambiguous characters (`0/O`, `1/l/I`, `|`)
- Must pass existing `password_complexity` validator
- Never persisted in plaintext, never written to audit logs
**New schemas** (`backend/app/schemas/admin.py`):
- `AdminUserCreate`: `email`, `name`, `account_mode` (enum: `existing` | `personal`), `account_display_code` (required when mode=existing), `account_role` (enum: `engineer` | `viewer`, required when mode=existing), `send_email` (bool, default=True)
- `AdminUserCreateResponse`: `user` (UserResponse), `temporary_password` (str), `email_sent` (bool)
**New endpoint** `POST /api/v1/admin/users`:
- Dependency: `require_super_admin`
- `existing` mode: validate account exists by display code, validate email unique, create user with `must_change_password=True`, assign to account with specified role
- `personal` mode: create account + user as owner with `must_change_password=True`. Note: if the subscription system isn't fully wired, personal mode creates Account + User only — subscription assignment is deferred.
- If `send_email=True`: send welcome email with temp password via Resend (best-effort, never blocks success)
- Return user + temp password (shown once to admin)
- Audit log: `user.create_admin` (no password value in log)
### Frontend Changes
**UsersPage.tsx** (`pages/admin/UsersPage.tsx`):
- "Create User" button opens modal
- Modal fields: email, name, account mode toggle (existing/personal), account selector by display code (shown when existing), role selector (shown when existing), send email toggle
- On success: show second modal with temp password, copy-to-clipboard button, and warning text ("This password will not be shown again")
**New API function** (`frontend/src/api/admin.ts`):
- `createUser(data: AdminUserCreate): Promise<AdminUserCreateResponse>`
### Key Files
- `backend/app/core/security.py`
- `backend/app/schemas/admin.py`
- `backend/app/api/endpoints/admin.py`
- `frontend/src/pages/admin/UsersPage.tsx`
- `frontend/src/api/admin.ts`
---
## Phase 3: Password Reset (Self-Service + Admin-Triggered)
### Database
**Migration 032**: `add_password_reset_tokens.py`
New table `password_reset_tokens`:
- `id`: UUID, primary key
- `token_hash`: String, unique, indexed (store bcrypt/SHA-256 hash of token, not plaintext)
- `user_id`: UUID, FK → users.id
- `expires_at`: DateTime(timezone=True)
- `used_at`: DateTime(timezone=True), nullable (null = unused)
- `created_by_admin_id`: UUID, nullable, FK → users.id (null = self-service)
- `created_at`: DateTime(timezone=True)
### Backend Changes
**Token generation** (`backend/app/core/security.py`):
- `create_password_reset_token(user_id, created_by_admin_id=None)`: Generate JWT with `{"sub": user_id, "type": "password_reset", "jti": unique_id, "exp": 30 minutes}`. Store hashed `jti` in `password_reset_tokens` table. Return the raw JWT.
- Token is single-use: enforced by checking `used_at IS NULL` for the hashed `jti` in the DB
**Email** (`backend/app/core/email.py`):
- `send_password_reset_email()`: HTML template matching ResolutionFlow branding with reset link `{FRONTEND_URL}/reset-password?token={token}`. Falls back to `http://localhost:5173` when `FRONTEND_URL is None and DEBUG=True`.
**Self-service endpoints** (`backend/app/api/endpoints/auth.py`):
`POST /api/v1/auth/password/forgot` (public):
- Rate limit: 3/minute
- Always returns generic success regardless of email existence (anti-enumeration)
- If email exists: create reset token, send email (best-effort)
- Audit log: `auth.password_reset.request`
`POST /api/v1/auth/password/verify-reset-token` (public):
- Validates JWT type, expiry, and that `jti` exists in DB and is unused
- Returns `{valid: bool, email: string}` (allows frontend to show the form or an error before the user fills it out)
`POST /api/v1/auth/password/reset` (public):
- Validates token (type, expiry, single-use via DB lookup)
- Sets new password (with complexity validation), clears `must_change_password`
- Marks token as used (`used_at = now`)
- Revokes all refresh tokens for user
- Audit log: `auth.password_reset.complete`
- Rate limit: 5/minute
**Admin reset endpoint** (`backend/app/api/endpoints/admin.py`):
`POST /api/v1/admin/users/{user_id}/password-reset`:
- Dependency: `require_super_admin`
- Request body: `mode` (enum: `email_link` | `temp_password`), `send_email` (bool, default=True)
- `email_link` mode: create reset token, send email, set `must_change_password=True`. Audit: `user.password_reset.admin_email`
- `temp_password` mode: generate temp password, hash and save, set `must_change_password=True`, return temp password once. Audit: `user.password_reset.admin_temp`
- Both modes: revoke all existing refresh tokens
**Expired token cleanup**: deferred to future maintenance task.
### Frontend Changes
**New pages**:
- `ForgotPasswordPage.tsx`: email input, calls forgot endpoint, shows generic success message
- `ResetPasswordPage.tsx`: reads `?token=` from URL, calls verify endpoint on mount (shows error or form), new password + confirm form, calls reset endpoint
**LoginPage.tsx**: Add "Forgot password?" link below login form
**Router** (`router.tsx`): Add `/forgot-password` and `/reset-password` as public routes
**Admin UI** (`pages/admin/UsersPage.tsx` or `UserDetailPage.tsx`):
- "Reset Password" action with mode picker (Email Link / Temporary Password)
- `email_link` result: success toast
- `temp_password` result: modal showing temp password with copy button + "won't be shown again" warning
**New API functions**:
- `auth.ts`: `forgotPassword()`, `verifyResetToken()`, `resetPassword()`
- `admin.ts`: `adminResetUserPassword(userId, mode, sendEmail)`
### Key Files
- `backend/alembic/versions/032_add_password_reset_tokens.py`
- `backend/app/core/security.py`
- `backend/app/core/email.py`
- `backend/app/schemas/auth_password.py`
- `backend/app/api/endpoints/auth.py`
- `backend/app/api/endpoints/admin.py`
- `frontend/src/pages/ForgotPasswordPage.tsx` (new)
- `frontend/src/pages/ResetPasswordPage.tsx` (new)
- `frontend/src/pages/LoginPage.tsx`
- `frontend/src/api/auth.ts`
- `frontend/src/api/admin.ts`
- `frontend/src/router.tsx`
---
## Phase 4: User Archive (Soft Delete) & Hard Delete
> **Permissions note:** All archive/restore/hard-delete endpoints use `require_super_admin` (not `require_admin`). Only super admins can perform these destructive user lifecycle operations.
### Database
**Migration 033**: `add_soft_delete_to_users.py`
Add to `users` table (follows same pattern as Tree model):
- `deleted_at`: DateTime(timezone=True), nullable, default=NULL
- `deleted_by`: UUID, nullable, FK → users.id, default=NULL
- Index on `deleted_at`
### Backend Changes
**User model** (`backend/app/models/user.py`):
- Add `deleted_at`, `deleted_by` fields
- Add `deleted_by_user` relationship (same pattern as Tree model's `deleted_by` relationship)
**Archive/Restore endpoints** (`backend/app/api/endpoints/admin.py`):
`PUT /api/v1/admin/users/{user_id}/archive`:
- Dependency: `require_super_admin`
- Sets `deleted_at=now`, `deleted_by=current_user.id`, `is_active=False`
- Revokes all refresh tokens for the archived user
- Prevents self-archive (return 400)
- Audit log: `user.archive`
`PUT /api/v1/admin/users/{user_id}/restore`:
- Dependency: `require_super_admin`
- Clears `deleted_at`, `deleted_by`, sets `is_active=True`
- Audit log: `user.restore`
**Hard delete endpoints** (`backend/app/api/endpoints/admin.py`) — both use `require_super_admin`:
`GET /api/v1/admin/users/{user_id}/hard-delete-check`:
- Dependency: `require_super_admin`
- Returns `{can_delete: bool, blockers: {...}}` with counts for each blocking FK reference
- Blocking references checked: `accounts.owner_id`, `sessions.user_id`, `audit_logs.user_id`, `invite_codes.created_by_id`, `invite_codes.used_by_id`, `account_invites.invited_by_id`, `account_invites.accepted_by_id`, `trees.author_id`, `trees.deleted_by`, `account_limit_override.created_by_id`, `feature_flags.created_by_id`, `platform_settings.updated_by_id`
`DELETE /api/v1/admin/users/{user_id}/hard-delete`:
- Dependency: `require_super_admin`
- Pre-conditions: user must be archived (`deleted_at IS NOT NULL`) AND precheck must pass (`can_delete=true`)
- If blockers exist: return 409 with structured blocker counts
- If no blockers: delete user row + clean technical auth artifacts (`refresh_tokens`, `password_reset_tokens`) in same transaction
- Prevents deleting other super admins (return 403)
- Audit log: `user.hard_delete`
**Update user listing**:
- `GET /api/v1/admin/users` accepts `include_archived: bool = Query(False)`
- Default query filters `deleted_at IS NULL`
- Archived users cannot authenticate (existing `is_active=False` check handles this)
**UserResponse schema updates**:
- Add `deleted_at: Optional[datetime]`, `deleted_by: Optional[UUID]`
### Frontend Changes
**UsersPage.tsx**:
- "Show Archived" toggle filter
- Archive/Restore action buttons per user (contextual based on state)
- Hard delete action: first calls precheck endpoint, displays dependency blockers if present, then shows destructive confirmation dialog if no blockers
**ConfirmDialog**: Strong warning for hard delete ("This action is permanent and cannot be undone")
**New API functions** (`frontend/src/api/admin.ts`):
- `archiveUser(userId)`, `restoreUser(userId)`
- `hardDeleteCheck(userId)`, `hardDeleteUser(userId)`
### Key Files
- `backend/alembic/versions/033_add_soft_delete_to_users.py`
- `backend/app/models/user.py`
- `backend/app/schemas/user.py`
- `backend/app/api/endpoints/admin.py`
- `frontend/src/pages/admin/UsersPage.tsx`
- `frontend/src/api/admin.ts`
---
## Phase 5: Quick Invite on Users Page
Thin convenience wrapper around existing invite infrastructure.
### Backend
**New endpoint** `POST /api/v1/admin/invites`:
- Dependency: `require_super_admin`
- Request: `{email, account_display_code, role}`
- Resolves account by display code, creates `AccountInvite`, sends email via existing `EmailService`
- Wraps existing invite logic — no new invite infrastructure
### Frontend
**UsersPage.tsx**: "Invite User" button → modal (email, account display code, role)
- Calls admin invite endpoint
- Shows success/error toast
### Key Files
- `backend/app/api/endpoints/admin.py`
- `frontend/src/pages/admin/UsersPage.tsx`
- `frontend/src/api/admin.ts`
---
## Security Summary
| Concern | Approach |
|---------|----------|
| **Temp passwords** | 16 chars, upper/lower/digit/symbol, excludes ambiguous chars. Never persisted plaintext. Never in audit logs. |
| **Reset tokens** | JWT with `type: "password_reset"`, 30-minute TTL, single-use enforced via DB table (`password_reset_tokens`). Always verify `type` claim to prevent token misuse. |
| **Anti-enumeration** | `/auth/password/forgot` returns identical response regardless of email existence. |
| **must_change_password** | Backend middleware enforcement — blocks all authenticated routes except allowlist. Frontend redirect is supplementary, not primary. |
| **Session invalidation** | Revoke ALL refresh tokens on: password change, password reset (self-service or admin), and user archive. |
| **Self-protection** | Admin cannot archive or delete themselves. Hard delete refuses other super admins. |
| **Rate limiting** | `forgot`: 3/min. `reset`: 5/min. `change`: 5/min. Admin endpoints use existing admin rate limits + audit logging. |
---
## Audit Events
All events include non-sensitive details only (no token or password values).
| Event | Trigger |
|-------|---------|
| `auth.password_change` | User changes own password (forced or voluntary) |
| `auth.password_reset.request` | Self-service forgot password request |
| `auth.password_reset.complete` | Self-service reset completed |
| `user.create_admin` | Admin creates new user |
| `user.archive` | Admin archives user |
| `user.restore` | Admin restores archived user |
| `user.hard_delete` | Admin hard-deletes user |
| `user.password_reset.admin_email` | Admin triggers email-link reset |
| `user.password_reset.admin_temp` | Admin generates temp password |
---
## Verification & Testing
### Automated Tests (pytest)
- Admin create user (existing account mode): returns temp password, stores hash, sets `must_change_password=True`, logs audit
- Admin create user (personal mode): creates account + owner role, logs audit
- Archive/restore toggles state correctly; archived users excluded from default list; archived users cannot authenticate
- Hard-delete precheck returns accurate blocker counts; delete rejected with blockers; delete succeeds when archived + no blockers
- Admin reset `email_link` mode: creates valid one-time token, best-effort email
- Admin reset `temp_password` mode: rotates password, sets `must_change_password=True`, returns temp, no plaintext persistence
- Self-service forgot: generic success for existing and non-existing email
- Reset token: enforces type, expiry, single-use, and complexity validation
- Verify-reset-token: returns valid/invalid correctly
- In-session password change: requires correct current password, revokes all refresh tokens
- `must_change_password` middleware: blocks non-allowlisted endpoints, allows allowlisted ones
- Self-protection: admin can't archive/delete self; can't hard-delete other super admins
### Frontend Build
- `cd frontend && npm run build` passes
### Manual Test Flows
1. **Admin creates user** → temp password shown → login with temp password → forced to /change-password → set new password → full app access
2. **Forgot password** → click link on login page → enter email → receive email → click link → token verified → set new password → login works
3. **Admin sends email reset** → user gets email → click link → set new password → login works
4. **Admin generates temp password** → admin sees temp password once → gives to user → user logs in → forced to change → works
5. **Archive user** → user can't login → admin restores → user can login again
6. **Hard delete** → precheck shows blockers → resolve blockers → precheck passes → confirm delete → user record gone
---
## Assumptions & Defaults
- Reset token TTL: 30 minutes
- Email delivery is best-effort; never blocks create/reset success responses
- Archived users remain unique by email; reusing email requires successful hard delete
- Hard delete requires prior archive state (two-step safety)
- Existing user list pagination is out of scope unless incidentally touched
- `password_reset_tokens` table cleaned up periodically (expired tokens can be pruned via scheduled task — not in initial scope)

View File

@@ -1,121 +0,0 @@
# Export Improvements Phase B — Design
> **Date:** 2026-02-13
> **Depends on:** Phase A (complete on `feat/export-phase-a`)
> **Scope:** B1 (Summary Block), B2 (Custom Step Markers), B3 (Detail Levels), B4 (Editable Preview)
---
## Decisions Made
| Question | Decision |
|----------|----------|
| B1 summary form vs single editable preview | Single editable preview (B4). No separate structured form for summary fields. |
| Detail levels | standard/full only. Dropped "summary" level — primary users are engineers, not dispatchers. |
| Mid-session editable preview | No. TreeNavigationPage keeps quick one-click copy. Editable preview is SessionDetailPage only. |
---
## 1. Schema Changes
Add to `SessionExport` (backend + frontend):
```python
include_summary: bool = False
detail_level: Literal["standard", "full"] = "standard"
```
No database migration needed — these are export-time options only.
---
## 2. Custom Step Differentiation (B2)
Detect custom steps by checking `node_id.startswith("custom-")` in each decision dict.
**Markdown:**
```markdown
### Step 5: [CUSTOM] Check Additional Event Logs
*Custom step added by engineer*
```
**Text:**
```
5. [CUSTOM] Check Additional Event Logs
```
**HTML:**
```html
<span style="background: #7c3aed; color: white; padding: 2px 6px; border-radius: 3px; font-size: 0.75em; margin-right: 6px;">CUSTOM</span>
```
**PSA:**
```
5. [CUSTOM] Check Additional Event Logs -> ...
```
Pure backend change — all 4 generators in `export_service.py`.
---
## 3. Summary Block (B1)
When `include_summary=True`, insert a Summary section after metadata, before Evidence/Steps.
Auto-populated fields:
| Field | Source |
|-------|--------|
| Issue | Tree name + description |
| Impact | `[Edit in preview]` placeholder |
| Status | "Resolved" if completed, else "In Progress — paused at step N" |
| Resolution | `outcome_notes` if available |
| Next Steps | `next_steps` if available |
Blank/placeholder fields are editable in the preview modal (B4). Format varies by generator (markdown table, text key-value, HTML styled table, PSA `--- SUMMARY ---` section).
The summary block is opt-in (`include_summary=False` default), independent of detail level.
---
## 4. Detail Levels (B3)
Two levels:
- **standard** (default): Current behavior, except command outputs >5 lines are truncated with `*(full output omitted — N lines)*`
- **full**: No truncation. All command outputs, scratchpad, notes rendered completely.
Implementation: Helper function `_truncate_command_output(output, max_lines=5)` used in all 4 generators when `detail_level="standard"`.
Frontend: Dropdown on SessionDetailPage export controls — "Standard" / "Full Detail".
---
## 5. Editable Preview (B4)
Modify `ExportPreviewModal`:
- Replace read-only `<pre>` with editable `<textarea>`
- Local state `editedContent` initialized from `content` prop
- "Copy" copies `editedContent`
- "Download" downloads `editedContent`
- "Reset" button restores original content
- "Include Summary" checkbox re-fetches export with `include_summary=true`
- Edits are NOT saved back to the session
Only applies to SessionDetailPage. TreeNavigationPage keeps instant "Copy for Ticket" behavior.
---
## Files Affected
**Backend:**
- `backend/app/schemas/session.py` — Add `include_summary`, `detail_level` to `SessionExport`
- `backend/app/services/export_service.py` — All 4 generators: custom step markers, summary block, command output truncation
**Frontend:**
- `frontend/src/types/session.ts` — Add `include_summary`, `detail_level` to `SessionExport`
- `frontend/src/components/session/ExportPreviewModal.tsx` — Editable textarea, reset, summary toggle
- `frontend/src/pages/SessionDetailPage.tsx` — Detail level dropdown, pass new options to export calls
**No migration needed.**

View File

@@ -1,898 +0,0 @@
# Export Improvements Phase B — Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Add summary block, custom step markers, detail levels (standard/full), and editable preview modal to the export system.
**Architecture:** Backend changes to all 4 export generators (markdown, text, HTML, PSA) + schema additions. Frontend changes to ExportPreviewModal (editable textarea) and SessionDetailPage (detail level dropdown, summary toggle). No migration needed.
**Tech Stack:** Python FastAPI, Pydantic v2, React 19, TypeScript, Tailwind CSS
**Prerequisites:** Phase A frontend must be merged first (branch `feat/export-phase-a`). Task 7 depends on `maxStepIndex` state and `handleCopyForTicket` from Phase A.
---
## Task 1: Add Phase B Fields to Backend Schema
**Files:**
- Modify: `backend/app/schemas/session.py:84-91`
**Step 1: Add `include_summary` and `detail_level` to `SessionExport`**
```python
class SessionExport(BaseModel):
format: str = Field(default="markdown", pattern="^(text|markdown|html|psa)$")
include_timestamps: bool = True
include_tree_info: bool = True
# Phase A
include_outcome_notes: bool = True
include_next_steps: bool = True
max_step_index: Optional[int] = Field(None, ge=1, description="1-based inclusive step cutoff")
# Phase B
include_summary: bool = False
detail_level: Literal["standard", "full"] = "standard"
```
Note: `Literal` is already imported at line 2.
**Step 2: Run tests to verify no regressions**
Run: `cd /c/Dev/Projects/patherly/backend && python -m pytest tests/test_psa_export.py tests/test_export_security.py -v`
Expected: All existing export tests pass (new fields have defaults, so backward-compatible).
**Step 3: Commit**
```bash
git add backend/app/schemas/session.py
git commit -m "feat: add include_summary and detail_level to SessionExport schema"
```
---
## Task 2: Add Frontend Types for Phase B
**Files:**
- Modify: `frontend/src/types/session.ts:79-86`
**Step 1: Add `include_summary` and `detail_level` to `SessionExport` interface**
```typescript
export interface SessionExport {
format: 'text' | 'markdown' | 'html' | 'psa'
include_timestamps?: boolean
include_tree_info?: boolean
include_outcome_notes?: boolean
include_next_steps?: boolean
max_step_index?: number
include_summary?: boolean
detail_level?: 'standard' | 'full'
}
```
**Step 2: Build to verify**
Run: `cd /c/Dev/Projects/patherly/frontend && npx tsc --noEmit`
Expected: No type errors.
**Step 3: Commit**
```bash
git add frontend/src/types/session.ts
git commit -m "feat(frontend): add include_summary and detail_level to SessionExport type"
```
---
## Task 3: Custom Step Markers (B2) in All 4 Generators
**Files:**
- Modify: `backend/app/services/export_service.py`
This is a pure backend change. Detect custom steps by `node_id.startswith("custom-")` and prefix the step title with `[CUSTOM]`.
**Step 1: Update `generate_markdown_export`** (line 163)
Change:
```python
lines.append(f"### Step {i}: {question}")
```
To:
```python
is_custom = decision.get("node_id", "").startswith("custom-")
prefix = "[CUSTOM] " if is_custom else ""
lines.append(f"### Step {i}: {prefix}{question}")
if is_custom:
lines.append("*Custom step added by engineer*")
```
**Step 2: Update `generate_text_export`** (line 250)
Change:
```python
lines.append(f"\n{i}. {question}")
```
To:
```python
is_custom = decision.get("node_id", "").startswith("custom-")
prefix = "[CUSTOM] " if is_custom else ""
lines.append(f"\n{i}. {prefix}{question}")
```
**Step 3: Update `generate_html_export`** (line 342)
Change:
```python
html_parts.append(f'<h3>Step {i}: {question}</h3>')
```
To:
```python
is_custom = decision.get("node_id", "").startswith("custom-")
custom_badge = '<span style="background: #7c3aed; color: white; padding: 2px 6px; border-radius: 3px; font-size: 0.75em; margin-right: 6px;">CUSTOM</span>' if is_custom else ''
html_parts.append(f'<h3>{custom_badge}Step {i}: {question}</h3>')
```
**Step 4: Update `generate_psa_export`** (line 413)
Change:
```python
line = f"{i}. {question}"
```
To:
```python
is_custom = decision.get("node_id", "").startswith("custom-")
prefix = "[CUSTOM] " if is_custom else ""
line = f"{i}. {prefix}{question}"
```
**Step 5: Run tests**
Run: `cd /c/Dev/Projects/patherly/backend && python -m pytest tests/test_psa_export.py tests/test_export_security.py -v`
Expected: All pass. (Existing tests don't have custom steps, so markers won't appear — no regressions.)
**Step 6: Commit**
```bash
git add backend/app/services/export_service.py
git commit -m "feat: add [CUSTOM] markers to custom steps in all 4 export generators"
```
---
## Task 4: Command Output Truncation + Detail Levels (B3)
**Files:**
- Modify: `backend/app/services/export_service.py`
**Step 1: Add format-aware `_truncate_command_output` helper** (after `_get_command_output` at line 91)
The truncation suffix must match the output format — markdown uses `*(...)*`, text/PSA use plain `(...)`, HTML uses `<em>...</em>`.
```python
def _truncate_command_output(output: str, max_lines: int = 5, fmt: str = "text") -> str:
"""Truncate command output to max_lines for standard detail level.
Args:
fmt: One of "markdown", "text", "html", "psa" — controls suffix formatting.
"""
lines = output.splitlines()
if len(lines) <= max_lines:
return output
truncated = "\n".join(lines[:max_lines])
count = len(lines)
if fmt == "markdown":
suffix = f"*(full output omitted — {count} lines)*"
elif fmt == "html":
suffix = f"<em>(full output omitted — {count} lines)</em>"
else: # text, psa
suffix = f"(full output omitted — {count} lines)"
return f"{truncated}\n{suffix}"
```
**Step 2: Apply truncation in `generate_markdown_export`** (line 168)
After `if command_output := _get_command_output(decision):` add:
```python
if options.detail_level == "standard":
command_output = _truncate_command_output(command_output, fmt="markdown")
```
**Step 3: Apply truncation in `generate_text_export`** (line 255)
After `if command_output := _get_command_output(decision):` add:
```python
if options.detail_level == "standard":
command_output = _truncate_command_output(command_output, fmt="text")
```
**Step 4: Apply truncation in `generate_html_export`** (line 347)
After `if command_output := _get_command_output(decision):` add:
```python
if options.detail_level == "standard":
command_output = _truncate_command_output(command_output, fmt="html")
```
**Step 5: Apply truncation in `generate_psa_export`** (line 421)
After `if command_output := _get_command_output(decision):` add:
```python
if options.detail_level == "standard":
command_output = _truncate_command_output(command_output, fmt="psa")
```
**Step 6: Run tests**
Run: `cd /c/Dev/Projects/patherly/backend && python -m pytest tests/test_psa_export.py tests/test_export_security.py -v`
Expected: All pass (default `detail_level="standard"` but existing test command outputs are short).
**Step 7: Commit**
```bash
git add backend/app/services/export_service.py
git commit -m "feat: add format-aware command output truncation for standard detail level"
```
---
## Task 5: Summary Block Generation (B1)
**Files:**
- Modify: `backend/app/services/export_service.py`
Add summary block generation to all 4 generators. The summary is inserted after metadata, before Evidence/Steps. Only rendered when `options.include_summary is True`.
**Design decision — placeholder policy:** Empty fields (no outcome_notes, no next_steps) are left blank (empty string), NOT filled with `[Edit in preview]`. This avoids placeholder text leaking into copied/exported output. The frontend preview textarea is where users add missing info manually.
**Step 1: Add `_build_summary_fields` helper** (after `_get_outcome_label` at line 113)
```python
def _build_summary_fields(session: Session) -> dict[str, str]:
"""Build auto-populated summary fields from session data.
Empty fields are left blank — users fill them in via the editable preview.
"""
tree_name = session.tree_snapshot.get("name", "Troubleshooting Session")
tree_desc = session.tree_snapshot.get("description", "")
issue = f"{tree_name}: {tree_desc}" if tree_desc else tree_name
if session.completed_at:
status = "Resolved" if getattr(session, "outcome", None) == "resolved" else \
f"Completed — {_get_outcome_label(session) or 'Unknown'}"
else:
step_count = len(session.decisions) if session.decisions else 0
status = f"In Progress — paused at step {step_count}" if step_count else "In Progress"
_raw_notes = getattr(session, 'outcome_notes', None)
resolution = (_raw_notes if isinstance(_raw_notes, str) else '').strip()
_raw_next = getattr(session, 'next_steps', None)
next_steps = (_raw_next if isinstance(_raw_next, str) else '').strip()
return {
"issue": issue,
"impact": "",
"status": status,
"resolution": resolution,
"next_steps": next_steps,
}
```
**Step 2: Add `_escape_markdown_table` helper** (right after `_build_summary_fields`)
Pipe characters and newlines in values break markdown table cells:
```python
def _escape_markdown_table(value: str) -> str:
"""Escape value for use in a markdown table cell."""
return value.replace("|", "\\|").replace("\n", " ")
```
**Step 3: Add summary block to `generate_markdown_export`**
Insert after the tree_info block (after line 138, before the scratchpad section):
```python
if options.include_summary:
summary = _build_summary_fields(session)
esc = _escape_markdown_table
lines.append("## Summary")
lines.append("")
lines.append("| Field | Details |")
lines.append("|-------|---------|")
lines.append(f"| Issue | {esc(summary['issue'])} |")
lines.append(f"| Impact | {esc(summary['impact'])} |")
lines.append(f"| Status | {esc(summary['status'])} |")
lines.append(f"| Resolution | {esc(summary['resolution'])} |")
lines.append(f"| Next Steps | {esc(summary['next_steps'])} |")
lines.append("")
lines.append("---")
lines.append("")
```
**Step 4: Add summary block to `generate_text_export`**
Insert after tree_info block (after line 227, before scratchpad section):
```python
if options.include_summary:
summary = _build_summary_fields(session)
lines.append("SUMMARY")
lines.append("-" * 20)
lines.append(f"Issue: {summary['issue']}")
lines.append(f"Impact: {summary['impact']}")
lines.append(f"Status: {summary['status']}")
lines.append(f"Resolution: {summary['resolution']}")
lines.append(f"Next Steps: {summary['next_steps']}")
lines.append("")
```
**Step 5: Add summary block to `generate_html_export`**
Insert after tree_info `</div>` (after line 321, before scratchpad section):
```python
if options.include_summary:
summary = _build_summary_fields(session)
html_parts.append('<h2>Summary</h2>')
html_parts.append('<table style="width: 100%; border-collapse: collapse; margin-bottom: 20px;">')
for label, value in [("Issue", summary["issue"]), ("Impact", summary["impact"]),
("Status", summary["status"]), ("Resolution", summary["resolution"]),
("Next Steps", summary["next_steps"])]:
html_parts.append(f'<tr><td style="padding: 6px 12px; border: 1px solid #ddd; font-weight: bold; width: 120px;">{html.escape(label)}</td>')
html_parts.append(f'<td style="padding: 6px 12px; border: 1px solid #ddd;">{html.escape(value)}</td></tr>')
html_parts.append('</table>')
```
**Step 6: Add summary block to `generate_psa_export`**
Insert after `lines.append("")` on line 394 (after the header, before PROBLEM section):
```python
if options.include_summary:
summary = _build_summary_fields(session)
lines.append("--- SUMMARY ---")
lines.append(f"Issue: {summary['issue']}")
lines.append(f"Impact: {summary['impact']}")
lines.append(f"Status: {summary['status']}")
lines.append(f"Resolution: {summary['resolution']}")
lines.append(f"Next Steps: {summary['next_steps']}")
lines.append("")
```
**Step 7: Run tests**
Run: `cd /c/Dev/Projects/patherly/backend && python -m pytest tests/test_psa_export.py tests/test_export_security.py -v`
Expected: All pass (summary is opt-in, existing tests don't set `include_summary=True`).
**Step 8: Commit**
```bash
git add backend/app/services/export_service.py
git commit -m "feat: add summary block generation to all 4 export generators"
```
---
## Task 6: Editable Preview Modal (B4)
**Files:**
- Modify: `frontend/src/components/session/ExportPreviewModal.tsx`
**Design decision — edit preservation on summary toggle:** Toggling "Include Summary" re-fetches from the backend, which resets `editedContent`. This is documented and expected — the toast says "Summary updated" so the user understands. Edits are lightweight (engineers tweak a few words), so the cost of re-typing is low vs. the complexity of merging diffs.
**Step 1: Replace read-only preview with editable textarea and add controls**
Rewrite the component:
```tsx
import { useState, useEffect } from 'react'
import { Copy, Download, Check, RotateCcw } from 'lucide-react'
import { Modal } from '@/components/common/Modal'
import { cn } from '@/lib/utils'
interface ExportPreviewModalProps {
isOpen: boolean
onClose: () => void
content: string
filename: string
format: 'markdown' | 'text' | 'html' | 'psa'
onDownload: (content: string) => void
includeSummary?: boolean
onToggleSummary?: (include: boolean) => void
}
export function ExportPreviewModal({
isOpen,
onClose,
content,
filename,
format,
onDownload,
includeSummary = false,
onToggleSummary,
}: ExportPreviewModalProps) {
const [copied, setCopied] = useState(false)
const [editedContent, setEditedContent] = useState(content)
// Sync editedContent when content prop changes (new fetch / summary toggle)
useEffect(() => {
setEditedContent(content)
}, [content])
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(editedContent)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch (err) {
console.error('Failed to copy:', err)
}
}
const handleDownload = () => {
onDownload(editedContent)
onClose()
}
const handleReset = () => {
setEditedContent(content)
}
const handleClose = () => {
setCopied(false)
onClose()
}
const isModified = editedContent !== content
return (
<Modal isOpen={isOpen} onClose={handleClose} title="Export Preview" size="xl">
{/* Filename, format info, and controls */}
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<p className="text-sm text-white/70">
Filename: <span className="font-mono text-white">{filename}</span>
<span className="ml-3 rounded bg-white/10 px-2 py-0.5 text-xs text-white/70">
{format === 'markdown' ? 'Markdown' : format === 'html' ? 'HTML' : format === 'psa' ? 'PSA' : 'Plain Text'}
</span>
{isModified && (
<span className="ml-2 text-xs text-yellow-400">(edited)</span>
)}
</p>
<div className="flex items-center gap-3">
{onToggleSummary && (
<label className="flex items-center gap-2 text-sm text-white/60 cursor-pointer">
<input
type="checkbox"
checked={includeSummary}
onChange={(e) => onToggleSummary(e.target.checked)}
className="h-4 w-4 rounded border-white/20 bg-black/50"
/>
Include Summary
</label>
)}
{isModified && (
<button
onClick={handleReset}
className="flex items-center gap-1 text-xs text-white/40 hover:text-white"
title="Reset to original"
>
<RotateCcw className="h-3 w-3" />
Reset
</button>
)}
</div>
</div>
{/* Editable Content */}
<textarea
value={editedContent}
onChange={(e) => setEditedContent(e.target.value)}
className={cn(
'h-96 w-full resize-y rounded-md border border-white/10 bg-black/50 p-4',
'font-mono text-sm text-white',
'focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20'
)}
/>
{/* Actions */}
<div className="mt-4 flex items-center justify-end gap-2">
<button
onClick={handleCopy}
className={cn(
'flex items-center gap-2 rounded-md border border-white/10 px-3 py-2 text-sm font-medium',
'text-white/60 hover:bg-white/10 hover:text-white',
'focus:outline-none focus:ring-2 focus:ring-white/20'
)}
>
{copied ? (
<>
<Check className="h-4 w-4 text-emerald-400" />
Copied!
</>
) : (
<>
<Copy className="h-4 w-4" />
Copy to Clipboard
</>
)}
</button>
<button
onClick={handleDownload}
className={cn(
'flex items-center gap-2 rounded-md bg-white px-3 py-2 text-sm font-medium text-black',
'hover:bg-white/90 focus:outline-none focus:ring-2 focus:ring-white/20'
)}
>
<Download className="h-4 w-4" />
Download
</button>
</div>
</Modal>
)
}
export default ExportPreviewModal
```
Key changes:
- `<pre>``<textarea>` with `editedContent` local state
- Copy/Download use `editedContent` instead of `content` prop
- Reset button appears when content is modified
- `onDownload` signature changes to `(content: string) => void` to receive edited content
- New optional `includeSummary` + `onToggleSummary` props for summary checkbox
- `(edited)` indicator when content has been modified
- Summary toggle re-fetches content, resetting edits (documented behavior — avoids diff-merge complexity)
**Step 2: Build to verify**
Run: `cd /c/Dev/Projects/patherly/frontend && npx tsc --noEmit`
Fix any type errors from the `onDownload` signature change (see Task 7).
**Step 3: Commit**
```bash
git add frontend/src/components/session/ExportPreviewModal.tsx
git commit -m "feat(frontend): convert ExportPreviewModal to editable textarea with reset"
```
---
## Task 7: Wire Up Phase B Controls in SessionDetailPage
**Files:**
- Modify: `frontend/src/pages/SessionDetailPage.tsx`
**Step 1: Add state for detail level and include summary** (after line 34)
```typescript
const [detailLevel, setDetailLevel] = useState<'standard' | 'full'>('standard')
const [includeSummary, setIncludeSummary] = useState(false)
```
**Step 2: Update `fetchExportContent` to include Phase B options** (line 92-101)
```typescript
const fetchExportContent = async () => {
if (!session) return null
const options: SessionExport = {
format: exportFormat,
include_timestamps: true,
include_tree_info: true,
...(maxStepIndex !== null && { max_step_index: maxStepIndex }),
detail_level: detailLevel,
include_summary: includeSummary,
}
return await sessionsApi.export(session.id, options)
}
```
**Step 3: Update `handleCopyForTicket` to include Phase B options** (line 140-145)
```typescript
const options: SessionExport = {
format: 'psa',
include_timestamps: true,
include_tree_info: true,
...(maxStepIndex !== null && { max_step_index: maxStepIndex }),
detail_level: detailLevel,
include_summary: includeSummary,
}
```
**Step 4: Update `handleDownload` to accept content parameter** (line 159)
Change:
```typescript
const handleDownload = () => {
if (!exportContent || !session) return
const blob = new Blob([exportContent], { type: 'text/plain' })
```
To:
```typescript
const handleDownload = (content: string) => {
if (!session) return
const blob = new Blob([content], { type: 'text/plain' })
```
**Step 5: Add `onToggleSummary` handler** with error handling
```typescript
const handleToggleSummary = async (include: boolean) => {
setIncludeSummary(include)
if (!session) return
const options: SessionExport = {
format: exportFormat,
include_timestamps: true,
include_tree_info: true,
...(maxStepIndex !== null && { max_step_index: maxStepIndex }),
detail_level: detailLevel,
include_summary: include,
}
try {
const content = await sessionsApi.export(session.id, options)
if (content) {
setExportContent(content)
toast.success(include ? 'Summary added' : 'Summary removed')
}
} catch (err) {
console.error('Failed to re-fetch export:', err)
toast.error('Failed to update export')
setIncludeSummary(!include) // Revert checkbox on failure
}
}
```
**Step 6: Add detail level dropdown** to the export controls area (after step cutoff dropdown, before copy button — after line 408)
```tsx
<select
value={detailLevel}
onChange={(e) => setDetailLevel(e.target.value as 'standard' | 'full')}
aria-label="Detail level"
className={cn(
'rounded-md border border-white/10 bg-black/50 px-3 py-2 text-sm text-white',
'focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20'
)}
>
<option value="standard">Standard</option>
<option value="full">Full Detail</option>
</select>
```
**Step 7: Update ExportPreviewModal props** (line 516-523)
```tsx
<ExportPreviewModal
isOpen={showPreview}
onClose={() => setShowPreview(false)}
content={exportContent || ''}
filename={getFilename()}
format={exportFormat}
onDownload={handleDownload}
includeSummary={includeSummary}
onToggleSummary={handleToggleSummary}
/>
```
**Step 8: Build to verify**
Run: `cd /c/Dev/Projects/patherly/frontend && npx tsc --noEmit`
Expected: No type errors.
**Step 9: Commit**
```bash
git add frontend/src/pages/SessionDetailPage.tsx
git commit -m "feat(frontend): add detail level dropdown and summary toggle to export controls"
```
---
## Task 8: Backend Tests for Phase B Features
**Files:**
- Modify: `backend/tests/test_psa_export.py`
Uses existing `_make_session` helper and `_default_options` from `test_psa_export.py`. Also imports additional generators for cross-format tests.
**Step 1: Add imports** at the top of `test_psa_export.py`
```python
from app.services.export_service import (
generate_psa_export, generate_text_export, generate_markdown_export,
generate_html_export, _format_duration,
)
```
**Step 2: Add `TestPhaseB` test class** at the bottom of the file
```python
class TestPhaseB:
"""Tests for Phase B export features: custom markers, detail levels, summary."""
def test_custom_step_markers_psa(self):
"""Custom steps should have [CUSTOM] prefix in PSA export."""
session = _make_session(decisions=[
{"node_id": "node-1", "question": "Check DNS", "answer": "OK"},
{"node_id": "custom-abc123", "question": "Check Additional Logs", "answer": "Found error"},
])
options = SessionExport(format="psa")
result = generate_psa_export(session, options)
assert "[CUSTOM] Check Additional Logs" in result
assert "[CUSTOM] Check DNS" not in result
def test_custom_step_markers_markdown(self):
"""Custom steps should have [CUSTOM] prefix and subtitle in markdown."""
session = _make_session(decisions=[
{"node_id": "custom-xyz", "question": "Manual Check", "answer": "Done"},
])
options = SessionExport(format="markdown")
result = generate_markdown_export(session, options)
assert "[CUSTOM] Manual Check" in result
assert "*Custom step added by engineer*" in result
def test_custom_step_markers_html(self):
"""Custom steps should have purple badge in HTML export."""
session = _make_session(decisions=[
{"node_id": "custom-xyz", "question": "Manual Check", "answer": "Done"},
])
options = SessionExport(format="html")
result = generate_html_export(session, options)
assert "CUSTOM</span>" in result
def test_command_output_truncation_standard(self):
"""Standard detail level truncates long command output."""
long_output = "\n".join(f"line {i}" for i in range(20))
session = _make_session(decisions=[
{"node_id": "node-1", "question": "Run diagnostics", "answer": "See output",
"command_output": long_output},
])
options = SessionExport(format="text", detail_level="standard")
result = generate_text_export(session, options)
assert "(full output omitted — 20 lines)" in result
assert "line 19" not in result
def test_command_output_full_detail(self):
"""Full detail level shows all command output."""
long_output = "\n".join(f"line {i}" for i in range(20))
session = _make_session(decisions=[
{"node_id": "node-1", "question": "Run diagnostics", "answer": "See output",
"command_output": long_output},
])
options = SessionExport(format="text", detail_level="full")
result = generate_text_export(session, options)
assert "(full output omitted" not in result
assert "line 19" in result
def test_truncation_short_output_unchanged(self):
"""Short command output is not truncated even in standard mode."""
short_output = "line 1\nline 2\nline 3"
session = _make_session(decisions=[
{"node_id": "node-1", "question": "Check", "answer": "OK",
"command_output": short_output},
])
options = SessionExport(format="text", detail_level="standard")
result = generate_text_export(session, options)
assert "(full output omitted" not in result
assert "line 3" in result
def test_truncation_markdown_format(self):
"""Markdown format uses italic truncation marker."""
long_output = "\n".join(f"line {i}" for i in range(20))
session = _make_session(decisions=[
{"node_id": "node-1", "question": "Check", "answer": "OK",
"command_output": long_output},
])
options = SessionExport(format="markdown", detail_level="standard")
result = generate_markdown_export(session, options)
assert "*(full output omitted — 20 lines)*" in result
def test_truncation_html_format(self):
"""HTML format uses <em> truncation marker."""
long_output = "\n".join(f"line {i}" for i in range(20))
session = _make_session(decisions=[
{"node_id": "node-1", "question": "Check", "answer": "OK",
"command_output": long_output},
])
options = SessionExport(format="html", detail_level="standard")
result = generate_html_export(session, options)
assert "<em>(full output omitted — 20 lines)</em>" in result
def test_summary_block_psa(self):
"""Summary block appears when include_summary is True."""
session = _make_session()
options = SessionExport(format="psa", include_summary=True)
result = generate_psa_export(session, options)
assert "--- SUMMARY ---" in result
assert "Issue:" in result
assert "Status:" in result
def test_no_summary_by_default(self):
"""Summary block should not appear by default."""
session = _make_session()
options = SessionExport(format="psa")
result = generate_psa_export(session, options)
assert "--- SUMMARY ---" not in result
def test_summary_block_markdown(self):
"""Summary block in markdown uses table format."""
session = _make_session()
options = SessionExport(format="markdown", include_summary=True)
result = generate_markdown_export(session, options)
assert "## Summary" in result
assert "| Issue |" in result
def test_summary_status_completed(self):
"""Completed resolved session shows 'Resolved' status in summary."""
session = _make_session()
session.outcome = "resolved"
options = SessionExport(format="psa", include_summary=True)
result = generate_psa_export(session, options)
assert "Status: Resolved" in result
def test_summary_status_in_progress(self):
"""In-progress session shows step count in summary status."""
session = _make_session(
decisions=[{"node_id": "n1", "question": "Step 1", "answer": "Done"}],
completed_at=None,
)
session.completed_at = None
options = SessionExport(format="psa", include_summary=True)
result = generate_psa_export(session, options)
assert "In Progress — paused at step 1" in result
def test_summary_empty_fields_no_placeholders(self):
"""Empty summary fields should be blank, not show placeholders."""
session = _make_session()
session.outcome_notes = None
session.next_steps = None
options = SessionExport(format="psa", include_summary=True)
result = generate_psa_export(session, options)
assert "[Edit in preview]" not in result
```
**Step 3: Run all export tests**
Run: `cd /c/Dev/Projects/patherly/backend && python -m pytest tests/test_psa_export.py -v`
Expected: All tests pass including new ones.
**Step 4: Commit**
```bash
git add backend/tests/test_psa_export.py
git commit -m "test: add Phase B tests for custom markers, detail levels, and summary block"
```
---
## Task 9: Final Build Verification
**Step 1: Run full backend tests**
Run: `cd /c/Dev/Projects/patherly/backend && python -m pytest --override-ini="addopts=" -v`
Expected: All tests pass.
**Step 2: Run frontend build**
Run: `cd /c/Dev/Projects/patherly/frontend && npm run build`
Expected: Build succeeds with no errors.
**Step 3: Verify git status is clean**
```bash
git status
git log --oneline feat/export-phase-a --not main | head -20
```
---
## Frontend Acceptance Checklist (Manual QA)
1. **Editable preview:** Open Preview, edit text, verify Copy/Download use edited content. Click Reset to restore original.
2. **Summary toggle:** Check "Include Summary" in preview — export re-fetches with summary block (edits reset, toast confirms). Uncheck removes it.
3. **Summary toggle error:** Disconnect network, toggle summary — checkbox reverts, error toast shown.
4. **Detail level:** Select "Full Detail", export a session with long command output — no truncation. Switch to "Standard" — output truncated with format-appropriate marker.
5. **Custom step markers:** Export a session with custom steps — should show `[CUSTOM]` prefix.
6. **Summary block content:** Summary should auto-populate Issue from tree name, Status from completion state, Resolution from outcome_notes. Empty fields are blank (no placeholder text).
7. **No placeholder leak:** Enable summary on a session with no outcome_notes — Resolution field should be blank, not show `[Edit in preview]`.

View File

@@ -1,57 +0,0 @@
# Phase C: Sensitive Data Redaction — Planning State
> **Status:** In planning — awaiting UI complexity decision
> **Spec:** `docs/plans/2026-02-13-EXPORT-IMPROVEMENTS-SPEC.md` section C1
> **Branch:** Not yet created (will be `feat/export-phase-c`)
## Open Question
**How much UI complexity for the redaction feature?**
The spec describes per-item highlighting and individual mask/unmask toggles, which requires replacing the textarea with a custom editor component. Three options:
1. **Simple toggle (Recommended)** — Server-side mask toggle in preview modal. "Mask Sensitive Data" checkbox re-fetches with redaction applied. User sees summary of what was found (e.g. "3 IPs, 2 emails masked"). Manual editing for fine-tuning. Keeps the existing textarea.
2. **Highlighted preview** — Replace textarea with a rich editor that highlights detected patterns in yellow. "Mask All" button replaces them. No per-item toggle.
3. **Full spec (per-item toggle)** — Rich editor with highlights + individual checkboxes per detected item. Most complex — requires custom editor component, match tracking, and state management for each item.
## What's Already Decided
### Backend Architecture
- New file: `backend/app/services/redaction_service.py`
- Redaction happens BEFORE generators (pre-process session copy)
- `redaction_mode: Literal["none", "mask"] = "none"` added to `SessionExport` schema
- Regex patterns: IPv4, IPv6, email, bearer tokens, API keys, UNC paths
- Hostname redaction: opt-in (MSP tickets legitimately need hostnames)
- Deep copy of session — original DB record never modified
- No migration needed
### Integration Point
In `backend/app/api/endpoints/sessions.py` line 296 (after session fetch, before generator call):
```python
if export_options.redaction_mode == "mask":
session = apply_redaction(session) # Returns sanitized copy
```
### Regex Patterns (conservative — false positives > false negatives)
- IPv4: `\b(?:\d{1,3}\.){3}\d{1,3}\b``[IP REDACTED]`
- IPv6: `\b(?:[0-9a-fA-F]{1,4}:){2,7}[0-9a-fA-F]{1,4}\b``[IP REDACTED]`
- Email: `\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b``[EMAIL REDACTED]`
- Bearer tokens: `Bearer\s+[A-Za-z0-9._-]+``[TOKEN REDACTED]`
- API key patterns: Long hex/base64 strings (32+ chars) → `[TOKEN REDACTED]`
- UNC paths: `\\\\[\w.-]+\\[\w$.-]+``[UNC PATH REDACTED]`
### Files to Modify
**Backend:**
- Create: `backend/app/services/redaction_service.py`
- Modify: `backend/app/schemas/session.py` (add `redaction_mode` to `SessionExport`)
- Modify: `backend/app/api/endpoints/sessions.py` (3-line integration)
**Frontend:**
- Modify: `frontend/src/types/session.ts` (add `redaction_mode` to `SessionExport`)
- Modify: `frontend/src/components/session/ExportPreviewModal.tsx` (add toggle)
- Modify: `frontend/src/pages/SessionDetailPage.tsx` (wire redaction state)
**Tests:**
- Create or extend: `backend/tests/test_psa_export.py` (add `TestPhaseC` class)

View File

@@ -0,0 +1,374 @@
# Visual QA & Design System Migration — Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Fix the collapsed sidebar scaling issue and migrate all pages from old monochrome design patterns to the new purple gradient accent design system.
**Architecture:** Systematic find-and-replace across 15 frontend files, replacing old monochrome CSS utilities (`glass-card`, `text-white/N`, `border-white/N`, `bg-white text-black` buttons) with new design tokens (`bg-card border-border`, `text-foreground`, `border-border`, `bg-gradient-brand`). Sidebar collapsed state gets a layout fix.
**Tech Stack:** React, Tailwind CSS v3, Lucide React
---
## Pattern Replacement Reference
Every task below uses this table. Keep it open.
| OLD Pattern | NEW Pattern | Context |
|---|---|---|
| `glass-card rounded-2xl` | `bg-card border border-border rounded-xl` | Card containers |
| `glass-card rounded-xl` | `bg-card border border-border rounded-xl` | Card containers |
| `bg-black` or `bg-black/50` | `bg-card` | Input/section backgrounds |
| `text-white` (on headings) | `text-foreground` + add `font-heading` | Primary heading text |
| `text-white` (on body text) | `text-foreground` | Primary body text |
| `text-white/70` | `text-muted-foreground` | Secondary text |
| `text-white/40` | `text-muted-foreground` | Muted text |
| `text-white/60` | `text-muted-foreground` | Button secondary text |
| `border-white/10` | `border-border` | Standard borders |
| `border-white/[0.06]` | `border-border` | Subtle borders |
| `border-white/20` | `border-border` | Toggle/active borders |
| `bg-white/10` | `bg-accent` | Hover/active backgrounds |
| `hover:bg-white/10` | `hover:bg-accent` | Hover states |
| `hover:text-white` | `hover:text-foreground` | Hover text |
| `bg-white text-black hover:bg-white/90` | `bg-gradient-brand text-white shadow-lg shadow-primary/20 hover:opacity-90` | Primary buttons |
| `bg-white px-N py-N text-black` ... `hover:bg-white/90` | `bg-gradient-brand text-white shadow-lg shadow-primary/20 hover:opacity-90` | Primary buttons (multi-class) |
| `border-white/10 ... text-white/60 ... hover:bg-white/10` | `border-border text-muted-foreground hover:bg-accent hover:text-foreground` | Secondary buttons |
| `focus:border-white/30 focus:ring-white/20` | `focus:border-primary focus:ring-1 focus:ring-primary/20` | Input focus |
| `placeholder:text-white/40` | `placeholder:text-muted-foreground` | Input placeholders |
| `bg-white/10 px-2.5 py-0.5` (badges) | `bg-accent rounded-full px-2 text-[0.6875rem] font-label` | Badge/chip styling |
---
## Task 1: Fix Collapsed Sidebar Layout
**Files:**
- Modify: `frontend/src/components/layout/Sidebar.tsx`
- Modify: `frontend/src/index.css`
**Problem:** When the sidebar is collapsed, the navigation divider doesn't span the full height (top to bottom), and the expand icon is extremely small — suggests the entire collapsed column isn't sizing correctly.
**Step 1: Fix collapsed sidebar to be a proper full-height flex column**
In `Sidebar.tsx`, the collapsed state renders a `<div className="px-2 py-3 space-y-1">` with nav icons but doesn't fill the column. The `<nav>` wrapper has `flex flex-col` but the collapsed branch lacks structure to fill it.
Replace the collapsed branch (lines 115-127) to add full-height structure with proper icon sizing and dividers:
```tsx
{sidebarCollapsed ? (
<>
{/* Collapsed: icon-only nav */}
<div className="flex flex-col items-center px-1.5 py-3 space-y-1">
<NavItem href="/" icon={LayoutGrid} label="Dashboard" collapsed />
<NavItem href="/trees" icon={Box} label="All Flows" matchPaths={['/trees', '/flows']} collapsed />
<NavItem href="/my-trees" icon={PenLine} label="Flow Editor" collapsed />
<NavItem href="/sessions" icon={Clock} label="Sessions" badge={activeSessionCount || undefined} collapsed />
<NavItem href="/shares" icon={FileText} label="Exports" collapsed />
<NavItem href="/step-library" icon={Bookmark} label="Step Library" collapsed />
</div>
</>
) : (
```
In the footer section (lines 173-189), ensure the toggle button has adequate sizing in collapsed mode:
```tsx
{/* Footer */}
<div className={cn(
'border-t border-[hsl(var(--border-subtle))]',
sidebarCollapsed ? 'px-1.5 py-2' : 'px-3 py-2 space-y-0.5'
)}>
{!sidebarCollapsed && (
<>
<NavItem href="/account" icon={Users} label="Team" />
<NavItem href="/account" icon={Settings} label="Settings" />
</>
)}
<button
onClick={toggleSidebar}
className={cn(
'flex items-center justify-center rounded-lg text-muted-foreground hover:bg-[hsl(var(--sidebar-hover))] hover:text-foreground transition-colors',
sidebarCollapsed ? 'w-full p-2.5' : 'w-full gap-3 px-3 py-2 text-[0.8125rem] font-medium'
)}
title={sidebarCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
>
{sidebarCollapsed ? <PanelLeftOpen size={20} /> : <PanelLeftClose size={18} />}
{!sidebarCollapsed && <span>Collapse</span>}
</button>
</div>
```
**Step 2: Verify build passes**
Run: `cd frontend && npm run build`
Expected: Build succeeds with no errors.
**Step 3: Commit**
```bash
git add frontend/src/components/layout/Sidebar.tsx
git commit -m "fix: collapsed sidebar layout scaling and toggle button size"
```
---
## Task 2: Migrate Auth Pages (Login, Register, ChangePassword, ResetPassword)
**Files:**
- Modify: `frontend/src/pages/LoginPage.tsx`
- Modify: `frontend/src/pages/RegisterPage.tsx`
- Modify: `frontend/src/pages/ChangePasswordPage.tsx`
- Modify: `frontend/src/pages/ResetPasswordPage.tsx`
**Step 1: Apply replacements to all four files**
Each file has the same three patterns:
1. `glass-card rounded-2xl``bg-card border border-border rounded-xl`
2. `border-white/10 bg-black/50``border-border bg-card`
3. `bg-white ... text-black ... hover:bg-white/90``bg-gradient-brand text-white shadow-lg shadow-primary/20 hover:opacity-90`
Also check for:
- Missing `font-heading` on page titles
- `text-white``text-foreground`
- Input focus states: `focus:border-white/30 focus:ring-white/20``focus:border-primary focus:ring-1 focus:ring-primary/20`
- Placeholder colors: `placeholder:text-white/40``placeholder:text-muted-foreground`
**Step 2: Verify build passes**
Run: `cd frontend && npm run build`
**Step 3: Commit**
```bash
git add frontend/src/pages/LoginPage.tsx frontend/src/pages/RegisterPage.tsx frontend/src/pages/ChangePasswordPage.tsx frontend/src/pages/ResetPasswordPage.tsx
git commit -m "refactor: migrate auth pages to new design system"
```
---
## Task 3: Migrate TreeLibraryPage
**Files:**
- Modify: `frontend/src/pages/TreeLibraryPage.tsx`
**Step 1: Apply replacements**
This file has extensive old patterns:
- Primary buttons: `bg-white ... text-black hover:bg-white/90``bg-gradient-brand text-white shadow-lg shadow-primary/20 hover:opacity-90`
- Cards: `glass-card``bg-card border border-border rounded-xl`
- Inputs: `border-white/10 bg-black/50 text-white placeholder:text-white/40``border-border bg-card text-foreground placeholder:text-muted-foreground`
- Focus states: `focus:border-white/30 focus:ring-white/20``focus:border-primary focus:ring-1 focus:ring-primary/20`
- Text: `text-white``text-foreground`, `text-white/40``text-muted-foreground`
- Borders: `border-white/10``border-border`
- Badges: `bg-white/10``bg-accent`
- Select dropdowns: `border-white/10 bg-black/50``border-border bg-card`
- Add `font-heading` to page title heading
**Step 2: Verify build passes**
Run: `cd frontend && npm run build`
**Step 3: Commit**
```bash
git add frontend/src/pages/TreeLibraryPage.tsx
git commit -m "refactor: migrate TreeLibraryPage to new design system"
```
---
## Task 4: Migrate SessionHistoryPage and SessionDetailPage
**Files:**
- Modify: `frontend/src/pages/SessionHistoryPage.tsx`
- Modify: `frontend/src/pages/SessionDetailPage.tsx`
**Step 1: Apply replacements**
SessionHistoryPage:
- `text-white` heading → `text-foreground` + `font-heading`
- `text-white/40``text-muted-foreground`
- `border-white/[0.06]``border-border`
- `glass-card rounded-2xl``bg-card border border-border rounded-xl`
- Primary buttons: `bg-white ... text-black hover:bg-white/90` → gradient
- Secondary buttons: `border-white/10 ... text-white/60 ... hover:bg-white/10``border-border text-muted-foreground hover:bg-accent`
SessionDetailPage:
- `text-white font-bold``text-foreground font-heading font-bold`
- `text-white/40``text-muted-foreground`
- `bg-white/10` badges → `bg-accent`
- Primary buttons: old → gradient
- `border-white/10 bg-black/50``border-border bg-card`
- `border-white/10 bg-transparent``border-border bg-card`
**Step 2: Verify build passes**
Run: `cd frontend && npm run build`
**Step 3: Commit**
```bash
git add frontend/src/pages/SessionHistoryPage.tsx frontend/src/pages/SessionDetailPage.tsx
git commit -m "refactor: migrate session pages to new design system"
```
---
## Task 5: Migrate TreeEditorPage
**Files:**
- Modify: `frontend/src/pages/TreeEditorPage.tsx`
**Step 1: Apply replacements**
- `glass-card rounded-2xl``bg-card border border-border rounded-xl`
- Primary buttons: `bg-white ... text-black hover:bg-white/90` → gradient
- `border-white/[0.06]``border-border`
- `text-lg font-semibold text-white``text-lg font-heading font-semibold text-foreground`
- Dark mode mixed patterns: `bg-yellow-100 ... dark:bg-yellow-900/30 dark:text-yellow-400` → just `bg-yellow-900/30 text-yellow-400 border border-yellow-500/20` (we're dark-only)
- `bg-white/10 text-white` toggle states → `bg-accent text-foreground`
- `border-white/10 bg-black/50``border-border bg-card`
**Step 2: Verify build passes**
Run: `cd frontend && npm run build`
**Step 3: Commit**
```bash
git add frontend/src/pages/TreeEditorPage.tsx
git commit -m "refactor: migrate TreeEditorPage to new design system"
```
---
## Task 6: Migrate TreeNavigationPage
**Files:**
- Modify: `frontend/src/pages/TreeNavigationPage.tsx`
This is the largest file with the most old patterns.
**Step 1: Apply replacements**
- `text-white` headings → `text-foreground` + `font-heading`
- `text-white/40``text-muted-foreground`
- `glass-card rounded-2xl``bg-card border border-border rounded-xl`
- `glass-card``bg-card border border-border`
- `border-white/10 bg-black/50``border-border bg-card`
- `border-white/10``border-border`
- `border-white/[0.06]``border-border`
- `bg-white/10``bg-accent`
- `text-white/70 hover:bg-white/10 hover:text-white``text-muted-foreground hover:bg-accent hover:text-foreground`
- Primary buttons: `bg-white ... text-black hover:bg-white/90` → gradient
- `border-purple-700 bg-purple-900/20``border-primary/30 bg-primary/10` (use design tokens, not hardcoded colors)
- `bg-white/10 px-2.5 py-0.5` badges → `bg-accent rounded-full px-2 text-[0.6875rem] font-label`
**Step 2: Verify build passes**
Run: `cd frontend && npm run build`
**Step 3: Commit**
```bash
git add frontend/src/pages/TreeNavigationPage.tsx
git commit -m "refactor: migrate TreeNavigationPage to new design system"
```
---
## Task 7: Migrate Session Sharing Components
**Files:**
- Modify: `frontend/src/pages/MySharesPage.tsx`
- Modify: `frontend/src/pages/SharedSessionPage.tsx`
- Modify: `frontend/src/components/session/ShareSessionModal.tsx`
**Step 1: Apply replacements**
MySharesPage:
- `text-white` heading → `text-foreground` + `font-heading`
- `text-white/40``text-muted-foreground`
- `glass-card rounded-xl``bg-card border border-border rounded-xl`
- Primary buttons: old → gradient
SharedSessionPage:
- `glass-card w-full max-w-md rounded-2xl``bg-card border border-border w-full max-w-md rounded-xl`
- `bg-white ... text-black hover:bg-white/90` → gradient
- `border-white/[0.06]``border-border`
- `text-lg font-semibold text-white``text-lg font-heading font-semibold text-foreground`
- `text-lg text-white/70``text-lg text-muted-foreground`
ShareSessionModal:
- `glass-card rounded-2xl``bg-card border border-border rounded-xl`
- `border-white/[0.06]``border-border`
- `border-white/20 bg-white/10` toggle → `border-border bg-accent`
- `border-white/10 bg-black/50``border-border bg-card`
- Primary buttons: old → gradient
**Step 2: Verify build passes**
Run: `cd frontend && npm run build`
**Step 3: Commit**
```bash
git add frontend/src/pages/MySharesPage.tsx frontend/src/pages/SharedSessionPage.tsx frontend/src/components/session/ShareSessionModal.tsx
git commit -m "refactor: migrate session sharing components to new design system"
```
---
## Task 8: Clean Up Legacy CSS
**Files:**
- Modify: `frontend/src/index.css`
**Step 1: Remove workspace dropdown animation (dead code)**
Remove the comment and `@keyframes dropIn` block (lines 115-119) — it was for the workspace switcher dropdown that no longer exists.
**Step 2: Verify build passes**
Run: `cd frontend && npm run build`
**Step 3: Commit**
```bash
git add frontend/src/index.css
git commit -m "chore: remove workspace dropdown animation (dead code)"
```
---
## Task 9: Final Grep Audit
**Step 1: Grep for remaining old patterns**
Search for all remaining instances of old patterns across the entire frontend:
```bash
grep -rn "glass-card\|text-white/\|border-white/\|bg-white text-black\|bg-black/50\|hover:bg-white/" frontend/src/ --include="*.tsx" --include="*.ts"
```
Any hits are stragglers to fix.
**Step 2: Grep for dark mode toggles**
```bash
grep -rn "dark:" frontend/src/ --include="*.tsx" --include="*.ts"
```
We're dark-only — `dark:` prefixes are dead code and should be cleaned to just the dark variant.
**Step 3: Fix any stragglers found**
**Step 4: Verify build passes**
Run: `cd frontend && npm run build`
**Step 5: Commit**
```bash
git add -A
git commit -m "refactor: clean up remaining old monochrome patterns"
```

View File

@@ -0,0 +1,364 @@
# Post-Implementation Improvements — Analytics & Feedback
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Context:** The analytics & feedback feature has just been implemented from `docs/plans/2026-02-15-analytics-feedback-implementation.md`. These are five targeted improvements identified from a comparative review. Apply them to the existing code on the current branch (`feat/analytics-feedback`).
**Reference files you'll need to modify:**
- `backend/app/api/endpoints/analytics.py` — analytics query logic
- `backend/app/schemas/analytics.py` — response schemas
- `backend/app/api/endpoints/ratings.py` — rating endpoints
- `backend/app/api/endpoints/steps.py` — existing step rating routes
- `frontend/src/types/analytics.ts` — TypeScript types
- `frontend/src/pages/TeamAnalyticsPage.tsx` — team dashboard
---
## Task 1: Switch from Average to Median Duration
**Why:** One 3-hour debugging session shouldn't skew stats for a flow that normally takes 8 minutes. Median is the standard for time-based metrics in analytics.
**Backend changes:**
In `backend/app/schemas/analytics.py`:
- Rename `avg_duration_minutes``median_duration_minutes` in `AnalyticsSummary`, `TopFlow`, and `TopEngineer`
In `backend/app/api/endpoints/analytics.py`:
- Replace `func.avg(...)` duration calculations with a median approach
- PostgreSQL supports `percentile_cont(0.5) WITHIN GROUP (ORDER BY ...)` for median
- Example replacement for the `_build_summary` helper:
```python
from sqlalchemy import text
# Replace the avg duration query with:
duration_q = await db.execute(
select(
func.percentile_cont(0.5).within_group(
func.extract('epoch', Session.completed_at - Session.started_at) / 60
)
).where(*base_filter, Session.completed_at.isnot(None))
)
median_duration = round(float(duration_q.scalar() or 0), 1)
```
- Apply the same change to the top_flows and top_engineers subqueries (replace `func.avg` with `percentile_cont(0.5)` for duration)
- Update all references from `avg_duration` to `median_duration` in the response construction
**Frontend changes:**
In `frontend/src/types/analytics.ts`:
- Rename `avg_duration_minutes``median_duration_minutes` in `TopFlow`, `TopEngineer`, and `AnalyticsSummary`
In `frontend/src/pages/TeamAnalyticsPage.tsx` and `frontend/src/pages/MyAnalyticsPage.tsx`:
- Update stat card label from "Avg Duration" → "Median Duration"
- Update any references to `avg_duration_minutes``median_duration_minutes`
In `frontend/src/components/analytics/FlowAnalyticsPanel.tsx`:
- Same rename for the flow summary stat card
**Verify:** `cd backend && python -m pytest --override-ini="addopts=" -v` and `cd frontend && npm run build`
**Commit:** `git commit -am "refactor: use median instead of average for duration metrics"`
---
## Task 2: Add Step Dropoff Metrics to Flow Analytics
**Why:** Dropoff data is passive (requires no user action) and tells flow authors exactly where engineers get stuck and abandon sessions. This is the most actionable metric for flow improvement.
**Backend changes:**
In `backend/app/schemas/analytics.py`:
- Add fields to `StepFeedbackSummary`:
```python
class StepFeedbackSummary(BaseModel):
node_id: str
node_title: str
helpful_yes: int
helpful_no: int
helpful_rate: float
visit_count: int = 0 # NEW
dropoff_count: int = 0 # NEW
dropoff_rate: float = 0.0 # NEW
```
In `backend/app/api/endpoints/analytics.py`, in the `get_flow_analytics` function:
- After the existing step_feedback section, add dropoff calculation logic:
```python
# Step dropoff analysis — build from session decisions JSONB
# For each session in the period for this tree:
# - Extract all node_ids from the decisions array
# - The LAST node_id in an incomplete session = dropoff point
# - Count visits per node and dropoffs per node
from sqlalchemy.dialects.postgresql import JSONB
# Get all sessions for this tree in period
sessions_q = await db.execute(
select(Session.id, Session.decisions, Session.completed_at)
.where(Session.tree_id == tree_id, Session.started_at >= period_start)
)
sessions_data = sessions_q.all()
# Build node visit and dropoff maps
node_visits: dict[str, int] = {}
node_dropoffs: dict[str, int] = {}
for sess in sessions_data:
decisions = sess.decisions or []
for decision in decisions:
node_id = decision.get("node_id") or decision.get("nodeId", "")
if node_id:
node_visits[node_id] = node_visits.get(node_id, 0) + 1
# If session not completed, last decision node is a dropoff
if not sess.completed_at and decisions:
last_decision = decisions[-1]
last_node = last_decision.get("node_id") or last_decision.get("nodeId", "")
if last_node:
node_dropoffs[last_node] = node_dropoffs.get(last_node, 0) + 1
# Merge dropoff data into step_feedback list
# Get node titles from the tree's content JSONB
tree_result = await db.execute(select(Tree.content).where(Tree.id == tree_id))
tree_content = tree_result.scalar_one_or_none() or {}
nodes = tree_content.get("nodes", [])
node_title_map = {n.get("id", ""): n.get("title", n.get("label", "Unnamed")) for n in nodes}
# Build combined step feedback with visits + dropoffs + thumbs
all_node_ids = set(list(node_visits.keys()) + [sf.node_id for sf in step_feedback])
combined_feedback = []
for nid in all_node_ids:
visits = node_visits.get(nid, 0)
dropoffs = node_dropoffs.get(nid, 0)
# Find existing thumbs data if any
existing = next((sf for sf in step_feedback if sf.node_id == nid), None)
combined_feedback.append(StepFeedbackSummary(
node_id=nid,
node_title=node_title_map.get(nid, "Unknown Step"),
helpful_yes=existing.helpful_yes if existing else 0,
helpful_no=existing.helpful_no if existing else 0,
helpful_rate=existing.helpful_rate if existing else 0.0,
visit_count=visits,
dropoff_count=dropoffs,
dropoff_rate=round(dropoffs / visits, 3) if visits > 0 else 0.0,
))
# Sort by dropoff_rate descending so worst steps are first
combined_feedback.sort(key=lambda x: x.dropoff_rate, reverse=True)
step_feedback = combined_feedback
```
**Frontend changes:**
In `frontend/src/types/analytics.ts`:
- Add to `StepFeedbackSummary`:
```typescript
export interface StepFeedbackSummary {
node_id: string
node_title: string
helpful_yes: number
helpful_no: number
helpful_rate: number
visit_count: number // NEW
dropoff_count: number // NEW
dropoff_rate: number // NEW
}
```
In `frontend/src/components/analytics/FlowAnalyticsPanel.tsx`:
- Add dropoff columns to the step feedback table: "Visits", "Dropoffs", "Dropoff Rate"
- Highlight rows where `dropoff_rate > 0.2` with a subtle red/warning background
**Verify:** `cd backend && python -m pytest --override-ini="addopts=" -v` and `cd frontend && npm run build`
**Commit:** `git commit -am "feat: add step dropoff metrics to flow analytics"`
---
## Task 3: Add Backward-Compatible /ratings Alias Routes
**Why:** The existing backend uses `/{step_id}/rate` but the frontend uses `/{step_id}/ratings`. This is a real inconsistency that needs fixing.
**Backend changes:**
In `backend/app/api/endpoints/steps.py`:
- Find the existing routes: `POST /{step_id}/rate`, `PUT /{step_id}/rate`, `DELETE /{step_id}/rate`
- Add alias routes that point to the same handler functions:
```python
# After the existing rate_step function:
@router.post("/{step_id}/ratings", response_model=StepRatingResponse, status_code=status.HTTP_201_CREATED,
include_in_schema=False)
async def rate_step_alias(
step_id: UUID,
rating_data: StepRatingCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Alias for POST /{step_id}/rate — backward compatibility."""
return await rate_step(step_id, rating_data, db, current_user)
@router.put("/{step_id}/ratings", response_model=StepRatingResponse,
include_in_schema=False)
async def update_rating_alias(
step_id: UUID,
rating_data: StepRatingUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Alias for PUT /{step_id}/rate — backward compatibility."""
return await update_rating(step_id, rating_data, db, current_user)
@router.delete("/{step_id}/ratings", status_code=204,
include_in_schema=False)
async def delete_rating_alias(
step_id: UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Alias for DELETE /{step_id}/rate — backward compatibility."""
return await delete_rating(step_id, db, current_user)
```
Note: Use `include_in_schema=False` to keep the OpenAPI docs clean — only the canonical `/rate` routes appear in docs.
**Verify:** `cd backend && python -m pytest --override-ini="addopts=" -v`
**Commit:** `git commit -am "fix: add /ratings alias routes for backward compatibility"`
---
## Task 4: Anonymize Feedback Comments in Analytics
**Why:** In a team tool where managers see feedback, engineers will give more honest feedback if their identity isn't attached. Anonymous feedback = better signal.
**Backend changes:**
In `backend/app/schemas/analytics.py`:
- Remove `user_name` from `FlowRatingItem`:
```python
class FlowRatingItem(BaseModel):
rating: int
comment: Optional[str]
created_at: datetime
# user_name removed — feedback is anonymous in analytics views
```
In `backend/app/api/endpoints/analytics.py`, in `get_flow_analytics`:
- Remove the `User.name` join from the recent_comments query:
```python
# Recent comments — anonymous
comments_q = await db.execute(
select(SessionRating.rating, SessionRating.comment, SessionRating.created_at)
.where(
SessionRating.tree_id == tree_id,
SessionRating.comment.isnot(None),
SessionRating.comment != "",
)
.order_by(SessionRating.created_at.desc())
.limit(10)
)
recent_comments = [
FlowRatingItem(
rating=row.rating,
comment=row.comment,
created_at=row.created_at,
)
for row in comments_q.all()
]
```
**Frontend changes:**
In `frontend/src/types/analytics.ts`:
- Remove `user_name` from `FlowRatingItem`:
```typescript
export interface FlowRatingItem {
rating: number
comment?: string
created_at: string
// user_name removed — anonymous feedback
}
```
In `frontend/src/components/analytics/FlowAnalyticsPanel.tsx`:
- Remove any rendering of `user_name` in the recent comments list
- Comments should show: star rating + comment text + relative timestamp only
**Verify:** `cd backend && python -m pytest --override-ini="addopts=" -v` and `cd frontend && npm run build`
**Commit:** `git commit -am "privacy: anonymize feedback comments in analytics views"`
---
## Task 5: Add Explicit active_engineers Metric
**Why:** "How many of my techs actually used this tool this month" is a key adoption metric for MSP managers.
**Backend changes:**
In `backend/app/schemas/analytics.py`:
- Verify `AnalyticsSummary` already has an `active_engineers` field. If not, add:
```python
class AnalyticsSummary(BaseModel):
total_sessions: int
completed_sessions: int
completion_rate: float
median_duration_minutes: float
active_engineers: int = 0 # ADD if missing
outcome_breakdown: OutcomeBreakdown
```
In `backend/app/api/endpoints/analytics.py`:
- In the `_build_summary` helper (or the team analytics endpoint), add the active engineers count:
```python
# Active engineers: distinct users with >= 1 session in window
active_q = await db.execute(
select(func.count(func.distinct(Session.user_id)))
.where(*base_filter)
)
active_engineers = active_q.scalar() or 0
```
- Include `active_engineers=active_engineers` in the `AnalyticsSummary(...)` construction
- For personal analytics (`/me`), set `active_engineers=1` (it's always just the requesting user)
**Frontend changes:**
In `frontend/src/types/analytics.ts`:
- Verify `AnalyticsSummary` has `active_engineers: number`. Add if missing.
In `frontend/src/pages/TeamAnalyticsPage.tsx`:
- Verify there's a stat card for "Active Engineers". If missing, add one using the `summary.active_engineers` value.
**Verify:** `cd backend && python -m pytest --override-ini="addopts=" -v` and `cd frontend && npm run build`
**Commit:** `git commit -am "feat: add explicit active_engineers metric to team analytics"`
---
## Final: Full Test Suite + Build Verification
```bash
cd backend && python -m pytest --override-ini="addopts=" -v
cd frontend && npm run build
```
Fix any failures, then:
```bash
git add -A
git commit -m "fix: post-improvement test and build fixes"
```

View File

@@ -1,458 +0,0 @@
# Subscription Tier Architecture — Design Document
> **Date:** 2026-02-05
> **Status:** Draft
> **Scope:** Subscription-based access control, Stripe integration, feature gating, and registration flow redesign
---
## Background
ResolutionFlow currently uses a flat role-based system (`engineer` / `viewer`) with boolean flags (`is_super_admin`, `is_team_admin`) for elevated permissions. This was built for internal/single-team use.
The product is moving to a SaaS model with three subscription tiers (Free, Pro, Team), Stripe billing, and a registration flow where permissions derive from **subscription plan + team role** rather than a standalone role field.
This document defines the architecture for that transition.
---
## Subscription Tiers
| | Free | Pro | Team |
|---|---|---|---|
| **Price** | $0 | TBD/month | TBD/month |
| **Billing** | None | Monthly or Annual | Monthly or Annual |
| **Users** | 1 | 1 | Tiered brackets (e.g., 1-5, 6-15, 16-50) |
| **Trees** | Limited (e.g., 3) | Higher limit (e.g., 25) | Unlimited or high limit |
| **Sessions/month** | Limited (e.g., 20) | Higher limit (e.g., 200) | Unlimited or high limit |
| **Custom branding** | No | No | Yes |
| **Priority support** | No | No | Yes |
| **Role assignment** | N/A (single user) | N/A (single user) | Team admin assigns roles |
> **Note:** Specific limits and pricing are TBD. The architecture supports changing these values without code changes (configured in the database, not hardcoded).
---
## Core Concepts
### Two-Layer Permission Model
The current single-layer model (`role` determines everything) is replaced by two layers:
1. **Subscription tier** → What **features and limits** you have access to
2. **Team role** → What you can **do** within your workspace (only meaningful for Team plans)
**How they interact:**
| Scenario | Subscription Tier | Team Role | What They Can Do |
|---|---|---|---|
| Free user signs up | Free | owner (implicit) | Access free-tier features, sole user in their account |
| Pro user signs up | Pro | owner (implicit) | Access pro-tier features, sole user in their account |
| Person buys Team plan | Team | owner | Full admin over team, assigns roles, manages billing |
| Invited to a Team | Team (inherited) | Assigned by team owner (engineer/viewer) | Access team-tier features, scoped by their assigned role |
| Free user joins a Team | Team (overrides Free) | Assigned by team owner | Gains team-tier access, loses individual billing |
### Account vs. User
This is the biggest conceptual shift. Today, `User` is the top-level entity. In the new model:
- **Account** — The billing entity. Has a subscription, owns the Stripe relationship. Every user belongs to exactly one account.
- **User** — A person. Authenticates, creates content, runs sessions. Has a role within their account.
A Free or Pro user has their own 1-person account. A Team account has multiple users. When a free user joins a team, their individual account is deactivated and they move under the team's account.
---
## Data Model Changes
### New Tables
#### `accounts`
The billing entity that replaces the direct user-to-subscription relationship.
```
accounts
├── id: UUID (PK)
├── name: String(255) -- User-chosen, not unique (UUID is the real identifier)
├── display_code: String(8) (unique) -- Auto-generated short code (e.g., "A7K2") for admin/internal disambiguation
├── owner_id: UUID (FK → users.id) -- The person who created/pays for this account
├── stripe_customer_id: String(255) -- Stripe customer ID
├── created_at: DateTime
└── updated_at: DateTime
```
#### `subscriptions`
Tracks the active Stripe subscription for an account.
```
subscriptions
├── id: UUID (PK)
├── account_id: UUID (FK → accounts.id, unique) -- One active sub per account
├── stripe_subscription_id: String(255) -- Stripe subscription ID
├── stripe_price_id: String(255) -- Which Stripe price (plan + interval)
├── plan: String(50) -- 'free', 'pro', 'team'
├── billing_interval: String(20) -- 'monthly', 'annual', or null (free)
├── status: String(50) -- 'active', 'past_due', 'canceled', 'trialing'
├── seat_limit: Integer -- Max users allowed (null = unlimited)
├── current_period_start: DateTime
├── current_period_end: DateTime
├── cancel_at_period_end: Boolean
├── created_at: DateTime
└── updated_at: DateTime
```
#### `plan_limits`
Configurable feature limits per plan — avoids hardcoding limits in application code.
```
plan_limits
├── id: UUID (PK)
├── plan: String(50) (unique) -- 'free', 'pro', 'team'
├── max_trees: Integer -- null = unlimited
├── max_sessions_per_month: Integer -- null = unlimited
├── max_users: Integer -- null = unlimited (overridden by subscription.seat_limit for tiered brackets)
├── custom_branding: Boolean
├── priority_support: Boolean
├── export_formats: JSONB -- e.g., ["txt", "md"] for free, ["txt", "md", "html", "pdf", "docx"] for paid
└── updated_at: DateTime
```
> This table is admin-seeded and rarely changes. It acts as a configuration table so you can adjust limits without deploying code.
#### `account_invites`
Replaces the current `invite_codes` table for team invitations (the existing invite code system for gating registration remains separate).
```
account_invites
├── id: UUID (PK)
├── account_id: UUID (FK → accounts.id)
├── invited_by_id: UUID (FK → users.id)
├── email: String(255) -- Pre-targeted invite (optional)
├── code: String(16) (unique) -- Shareable join code
├── role: String(50) -- Role they'll get when they join: 'engineer' or 'viewer'
├── accepted_by_id: UUID (FK → users.id, nullable)
├── expires_at: DateTime
├── created_at: DateTime
└── accepted_at: DateTime
```
### Modified Tables
#### `users` — Changes
```diff
users
├── id
├── email
├── password_hash
├── name
- ├── role: String(50) -- REMOVE standalone role
+ ├── account_id: UUID (FK → accounts.id) -- Every user belongs to an account
+ ├── account_role: String(50) -- 'owner', 'engineer', 'viewer'
├── is_super_admin: Boolean -- KEEP (system-level, not account-level)
- ├── is_team_admin: Boolean -- REMOVE (replaced by account_role = 'owner')
- ├── team_id: UUID -- REMOVE (replaced by account_id)
- ├── invite_code_id: UUID -- KEEP for registration gating
├── is_active: Boolean -- ADD (from security audit Phase B)
├── created_at
└── last_login
```
**Key changes:**
- `role``account_role` (renamed for clarity, values: `owner`, `engineer`, `viewer`)
- `team_id``account_id` (accounts replace teams as the grouping entity)
- `is_team_admin` → removed (account_role `owner` replaces this)
- The `owner` role is new — it's the person who created the account and manages billing
#### `teams` → Absorbed into `accounts`
The existing `teams` table is conceptually merged into `accounts`. A Team-plan account *is* a team. The `teams` table can either be:
- **Option A:** Dropped entirely, with `team_id` references on trees/categories/tags migrated to `account_id`
- **Option B:** Kept as a sub-grouping within large accounts (e.g., an MSP with multiple departments)
**Recommendation:** Option A for now. Sub-teams add complexity you don't need yet, and you can always add a `teams` table under accounts later.
### Relationship Diagram
```
Account (1) ──── (1) Subscription
│ │
│ └── references plan_limits
├──── (many) Users
│ └── account_role: owner/engineer/viewer
├──── (many) Trees
├──── (many) Categories
├──── (many) Tags
└──── (many) Account Invites
```
---
## Registration Flow Redesign
### Current Flow
```
User visits /register → enters name/email/password + invite code → gets role "engineer" → done
```
### New Flow
```
User visits /register
├── Step 1: Name, email, password
├── Step 2: Choose path
│ ├── "Start free" → Creates Account (free plan), account_role = owner
│ ├── "Start Pro plan" → Creates Account, redirects to Stripe Checkout
│ ├── "Start Team plan" → Creates team Account, names team, redirects to Stripe Checkout
│ └── "Join existing team" → Enter invite code → joins that Account with assigned role
└── Step 3: Email verification (from security audit backlog)
```
**For invite-based joins:**
- If the user already has a free/pro account, their individual account is deactivated (not deleted — preserves history)
- They're moved under the team's account with whatever role the invite specifies
- Their existing trees/sessions stay linked to them but become visible under the team account
**For team plan purchase:**
- The buyer becomes `account_role = 'owner'`
- They can generate invite codes/links for others
- Invited users either create new accounts or migrate existing ones into the team
### Stripe Checkout Integration
Registration for paid plans follows this sequence:
```
1. User completes registration form (account + user created in DB with plan='free' temporarily)
2. Frontend redirects to Stripe Checkout (passing account_id in metadata)
3. Stripe processes payment
4. Stripe sends webhook → backend updates subscription record
5. User redirected back to app with active paid plan
```
This avoids creating Stripe customers before you have a confirmed user, and handles payment failures gracefully (user still has a free account).
---
## Stripe Integration Architecture
### Stripe Objects Mapping
| ResolutionFlow | Stripe |
|---|---|
| Account | Customer |
| Subscription | Subscription |
| Plan + Interval | Price (linked to a Product) |
### Stripe Products & Prices to Create
```
Product: "ResolutionFlow Pro"
├── Price: $X/month (price_pro_monthly)
└── Price: $Y/year (price_pro_annual)
Product: "ResolutionFlow Team"
├── Price: $X/month per bracket (price_team_5_monthly, price_team_15_monthly, etc.)
└── Price: $Y/year per bracket (price_team_5_annual, price_team_15_annual, etc.)
```
### Webhook Events to Handle
| Event | Action |
|---|---|
| `checkout.session.completed` | Create/update subscription record, upgrade plan |
| `invoice.paid` | Update `current_period_start/end`, confirm active status |
| `invoice.payment_failed` | Set status to `past_due`, notify account owner |
| `customer.subscription.updated` | Sync plan changes (upgrades, downgrades, seat changes) |
| `customer.subscription.deleted` | Set status to `canceled`, downgrade to free tier limits |
### Webhook Security
- Verify Stripe signature on every webhook (`stripe.Webhook.construct_event`)
- Use a dedicated `/api/v1/webhooks/stripe` endpoint (no auth required, signature-verified)
- Store webhook events in an `events` table for debugging/replay
- Make webhook handlers idempotent (safe to process the same event twice)
---
## Feature Gating System
### How It Works
Instead of checking `user.role` to decide what someone can do, the app checks **two things**:
1. **Feature access** — Does this user's subscription plan include this feature? (checked against `plan_limits`)
2. **Permission** — Does this user's `account_role` allow this action? (owner > engineer > viewer)
### Backend: Middleware / Dependencies
```python
# New dependency: get the user's plan limits
async def get_plan_limits(current_user: User, db: Session) -> PlanLimits:
subscription = await get_account_subscription(current_user.account_id, db)
return await get_limits_for_plan(subscription.plan, db)
# New dependency: check a specific feature
async def require_feature(feature: str):
"""Factory for feature-gated dependencies."""
async def checker(limits: PlanLimits = Depends(get_plan_limits)):
if not getattr(limits, feature, False):
raise HTTPException(402, f"This feature requires a plan upgrade")
return checker
# Usage in endpoints:
@router.post("/trees")
async def create_tree(
current_user: User = Depends(require_engineer_or_above), # permission check
limits: PlanLimits = Depends(get_plan_limits), # feature check
):
tree_count = await count_user_trees(current_user.account_id, db)
if limits.max_trees and tree_count >= limits.max_trees:
raise HTTPException(402, "Tree limit reached. Upgrade your plan for more.")
...
```
### Frontend: Subscription Context
```typescript
// New context providing plan info to all components
const SubscriptionContext = React.createContext<{
plan: 'free' | 'pro' | 'team'
limits: PlanLimits
usage: CurrentUsage // e.g., { trees: 2, sessionsThisMonth: 15 }
canUseFeature: (feature: string) => boolean
isAtLimit: (resource: string) => boolean
}>()
// Usage in components:
const { isAtLimit, plan } = useSubscription()
// Disable "Create Tree" if at limit
<Button disabled={isAtLimit('trees')}>
{isAtLimit('trees') ? 'Upgrade to create more trees' : 'Create Tree'}
</Button>
```
### HTTP Status Codes
| Code | Meaning |
|---|---|
| 403 | Permission denied (wrong role) |
| 402 | Payment required (feature/limit needs upgrade) |
Using `402` for subscription-related blocks distinguishes them from role-based `403` errors, so the frontend can show an "upgrade" prompt instead of a "you don't have permission" message.
---
## Migration Strategy
### Phase 1: Database Migration
This is the most delicate part. The migration needs to:
1. Create `accounts` table
2. Create `subscriptions` table
3. Create `plan_limits` table and seed with initial values
4. Create `account_invites` table
5. For every existing user:
- Create an `Account` with plan `free`
- Set `user.account_id` to that account
- Map `user.role``user.account_role` (engineer → engineer, viewer → viewer)
- If `is_team_admin` was true → set `account_role = 'owner'`
6. For every existing team:
- Create an `Account` from the team (name, created_at)
- Move all team members' `account_id` to the new account
- Set the team admin as `account_role = 'owner'`
7. Migrate `team_id` references on trees, categories, tags, step_categories to `account_id`
8. Drop `team_id`, `is_team_admin`, `role` columns from users
9. Drop `teams` table
> **Important:** This migration should be tested extensively on a copy of the production database before running it for real.
### Phase 2: Backend Changes
- Add Stripe configuration to `config.py` (`STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, `STRIPE_PUBLISHABLE_KEY`)
- Create Stripe webhook endpoint
- Update all permission checks from `role`/`team_id` to `account_role`/`account_id`
- Add feature-gating dependencies
- Create account management endpoints (invite, remove user, change roles)
- Create subscription management endpoints (current plan, upgrade, cancel)
### Phase 3: Frontend Changes
- New registration flow with plan selection
- Stripe Checkout redirect integration
- Subscription context provider
- Account settings page (manage members, view plan, billing portal link)
- Upgrade prompts on feature-gated actions
- Usage indicators (e.g., "3 of 5 trees used")
### Phase 4: Stripe Dashboard Setup
- Create Products and Prices in Stripe
- Configure webhook endpoint URL
- Set up Stripe Customer Portal (for self-service billing management)
- Test the full flow in Stripe Test Mode
---
## Impact on Existing Features
### What Changes
| Feature | Before | After |
|---|---|---|
| Registration | Email + password + invite code | Email + password + plan selection (or invite code to join team) |
| "Who can see my trees?" | Based on `team_id` match | Based on `account_id` match |
| "Who can edit my tree?" | Author or team admin | Author or account owner |
| "Am I an admin?" | `is_team_admin` flag | `account_role == 'owner'` |
| Tree/session limits | None | Enforced per plan via `plan_limits` |
| Invite codes | Global registration gate | Two systems: registration gate (existing) + team invites (new) |
### What Stays the Same
- `is_super_admin` — Still a system-level flag for ResolutionFlow operators (you)
- Session ownership — Still scoped to the individual user
- Tree authorship — Still tracked per user
- The security audit fixes (Phases A-D) — All still apply, just with `account_id` instead of `team_id`
---
## Existing Invite Code System
The current `invite_codes` table serves as a **registration gate** — you must have a valid code to sign up at all. This is separate from team invites.
**Recommendation:** Keep both systems:
- `invite_codes` — Controls who can register for ResolutionFlow at all (beta access, controlled rollout)
- `account_invites` — Controls who can join a specific team account
Once ResolutionFlow is publicly available, you can disable the registration gate (`REQUIRE_INVITE_CODE=false`) while team invites remain active.
---
## Open Questions
1. **Free tier limits** — What specific numbers feel right? (trees, sessions/month)
2. **Pricing** — What price points for Pro and Team?
3. **Team seat brackets** — What brackets? (e.g., 1-5 at $X, 6-15 at $Y, 16-50 at $Z, 50+ custom)
4. **Free trial** — Should paid plans have a trial period? If so, how long?
5. **Downgrade behavior** — When a paid user cancels, do they keep their content but lose access to features? Or does content get archived?
6. **Existing user migration** — When this ships, should all current users become Pro or stay Free?
7. **Account owner transfer** — Can an owner transfer ownership to another user? (Important for when someone leaves a company)
8. **Multiple accounts** — Can one email belong to multiple accounts? (e.g., pro account + work team) Or is it strictly one account per user?
---
## References
- Stripe Checkout: https://docs.stripe.com/payments/checkout
- Stripe Customer Portal: https://docs.stripe.com/customer-management/portal-deep-dive
- Stripe Webhooks: https://docs.stripe.com/webhooks
- Current models: `backend/app/models/`
- Permissions audit: `docs/plans/2026-02-05-permissions-audit-design.md`