feat: add procedural flows with intake forms, navigation, and seed templates

Adds a new "procedural" tree type for linear step-by-step project workflows
(domain controller setup, M365 onboarding, VPN config, etc). Includes intake
form builder, two-panel step navigation, variable resolution, procedural
exports, 3 seed templates, and UI rename from "Trees" to "Flows".

Also archives 19 implemented plan docs and creates deferred features backlog.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
chihlasm
2026-02-14 04:13:52 -05:00
parent 303570ca2c
commit 350c977eda
58 changed files with 11686 additions and 167 deletions

View File

@@ -0,0 +1,146 @@
# Phase C: Sensitive Data Redaction — Design Document
> **Status:** Approved — ready for implementation planning
> **Spec:** `docs/plans/2026-02-13-EXPORT-IMPROVEMENTS-SPEC.md` section C1
> **UI Decision:** Simple toggle (Option 1)
> **Branch:** `feat/export-phase-c`
## Overview
Server-side regex redaction with a simple checkbox toggle in the export preview modal. No rich editor — keeps the existing textarea. User sees a summary of what was masked and can manually edit the result.
---
## Backend
### New File: `backend/app/services/redaction_service.py`
**`apply_redaction(session) -> tuple[Session, RedactionSummary]`**
- Deep-copies the session (original ORM object never mutated)
- Walks `decisions` list and `custom_steps`, applies regex replacements to all string fields: `answer`, `notes`, `command_output`, `content`, `action_performed`
- Also redacts top-level session fields: `scratchpad`, `outcome_notes`, `next_steps`
- Returns the sanitized copy and a summary of what was found
**`RedactionSummary` dataclass:**
```python
@dataclass
class RedactionSummary:
ips: int = 0
emails: int = 0
tokens: int = 0
unc_paths: int = 0
```
### Regex Patterns (conservative — false positives > false negatives)
| Pattern | Regex | Replacement |
|---------|-------|-------------|
| 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]` |
Hostname redaction is **not** included — MSP tickets legitimately reference hostnames.
### Schema Change: `backend/app/schemas/session.py`
Add to `SessionExport`:
```python
redaction_mode: Literal["none", "mask"] = "none"
```
### Integration Point: `backend/app/api/endpoints/sessions.py`
Insert at ~line 297 (after session fetch, before format branching):
```python
redaction_summary = None
if export_options.redaction_mode == "mask":
session, redaction_summary = apply_redaction(session)
```
Export generators receive `redaction_summary` and append a footer when present:
```
--- Redacted: 3 IPs, 2 emails, 1 token ---
```
### Response
The redaction summary is returned via an `X-Redaction-Summary` response header (JSON-encoded) to avoid changing the existing content-based response body.
### No Migration Needed
All changes are runtime — no database schema changes.
---
## Frontend
### `ExportPreviewModal.tsx`
New props:
- `redactionEnabled?: boolean`
- `onToggleRedaction?: (enabled: boolean) => void`
- `redactionSummary?: { ips: number; emails: number; tokens: number; unc_paths: number } | null`
Add a "Mask Sensitive Data" checkbox next to the existing "Include Summary" checkbox, using the same visual pattern:
```tsx
<label className="flex items-center gap-2 text-sm text-white/60 cursor-pointer">
<input type="checkbox" checked={redactionEnabled} onChange={...} />
Mask Sensitive Data
</label>
```
When `redactionSummary` has matches, show an info line below the toggles in `text-blue-400`:
```
Masked: 3 IPs, 2 emails, 1 token
```
If redaction is on but nothing was found: `"No sensitive data detected"` in `text-white/40`.
### `SessionDetailPage.tsx`
- Add `redactionMode` state (`'none' | 'mask'`)
- Wire into export options object
- Pass toggle callback to `ExportPreviewModal`
- Same pattern as existing `includeSummary` state
### `types/session.ts`
Add to `SessionExport` type:
```typescript
redaction_mode?: 'none' | 'mask'
```
---
## Testing
### Backend: `backend/tests/test_psa_export.py` — `TestPhaseC` class
- Test redaction of each pattern type individually (IP, email, bearer token, API key, UNC path)
- Test `redaction_mode="none"` leaves content untouched
- Test original session object is not mutated (deep copy verification)
- Test redaction summary counts are accurate
- Test redaction across all text fields (`notes`, `command_output`, `answer`, `scratchpad`, `outcome_notes`, `next_steps`)
- Test edge cases: empty strings, no matches, overlapping patterns
### Frontend
`npm run build` validates types. No new component tests needed for a checkbox toggle.
---
## Files to Create/Modify
| Action | File |
|--------|------|
| Create | `backend/app/services/redaction_service.py` |
| Modify | `backend/app/schemas/session.py` |
| Modify | `backend/app/api/endpoints/sessions.py` |
| Modify | `frontend/src/types/session.ts` |
| Modify | `frontend/src/components/session/ExportPreviewModal.tsx` |
| Modify | `frontend/src/pages/SessionDetailPage.tsx` |
| Extend | `backend/tests/test_psa_export.py` |

View File

@@ -0,0 +1,146 @@
# Phase C: Sensitive Data Redaction — Design Document
> **Status:** Approved — ready for implementation planning
> **Spec:** `docs/plans/2026-02-13-EXPORT-IMPROVEMENTS-SPEC.md` section C1
> **UI Decision:** Simple toggle (Option 1)
> **Branch:** `feat/export-phase-c`
## Overview
Server-side regex redaction with a simple checkbox toggle in the export preview modal. No rich editor — keeps the existing textarea. User sees a summary of what was masked and can manually edit the result.
---
## Backend
### New File: `backend/app/services/redaction_service.py`
**`apply_redaction(session) -> tuple[Session, RedactionSummary]`**
- Deep-copies the session (original ORM object never mutated)
- Walks `decisions` list and `custom_steps`, applies regex replacements to all string fields: `answer`, `notes`, `command_output`, `content`, `action_performed`
- Also redacts top-level session fields: `scratchpad`, `outcome_notes`, `next_steps`
- Returns the sanitized copy and a summary of what was found
**`RedactionSummary` dataclass:**
```python
@dataclass
class RedactionSummary:
ips: int = 0
emails: int = 0
tokens: int = 0
unc_paths: int = 0
```
### Regex Patterns (conservative — false positives > false negatives)
| Pattern | Regex | Replacement |
|---------|-------|-------------|
| 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]` |
Hostname redaction is **not** included — MSP tickets legitimately reference hostnames.
### Schema Change: `backend/app/schemas/session.py`
Add to `SessionExport`:
```python
redaction_mode: Literal["none", "mask"] = "none"
```
### Integration Point: `backend/app/api/endpoints/sessions.py`
Insert at ~line 297 (after session fetch, before format branching):
```python
redaction_summary = None
if export_options.redaction_mode == "mask":
session, redaction_summary = apply_redaction(session)
```
Export generators receive `redaction_summary` and append a footer when present:
```
--- Redacted: 3 IPs, 2 emails, 1 token ---
```
### Response
The redaction summary is returned via an `X-Redaction-Summary` response header (JSON-encoded) to avoid changing the existing content-based response body.
### No Migration Needed
All changes are runtime — no database schema changes.
---
## Frontend
### `ExportPreviewModal.tsx`
New props:
- `redactionEnabled?: boolean`
- `onToggleRedaction?: (enabled: boolean) => void`
- `redactionSummary?: { ips: number; emails: number; tokens: number; unc_paths: number } | null`
Add a "Mask Sensitive Data" checkbox next to the existing "Include Summary" checkbox, using the same visual pattern:
```tsx
<label className="flex items-center gap-2 text-sm text-white/60 cursor-pointer">
<input type="checkbox" checked={redactionEnabled} onChange={...} />
Mask Sensitive Data
</label>
```
When `redactionSummary` has matches, show an info line below the toggles in `text-blue-400`:
```
Masked: 3 IPs, 2 emails, 1 token
```
If redaction is on but nothing was found: `"No sensitive data detected"` in `text-white/40`.
### `SessionDetailPage.tsx`
- Add `redactionMode` state (`'none' | 'mask'`)
- Wire into export options object
- Pass toggle callback to `ExportPreviewModal`
- Same pattern as existing `includeSummary` state
### `types/session.ts`
Add to `SessionExport` type:
```typescript
redaction_mode?: 'none' | 'mask'
```
---
## Testing
### Backend: `backend/tests/test_psa_export.py` — `TestPhaseC` class
- Test redaction of each pattern type individually (IP, email, bearer token, API key, UNC path)
- Test `redaction_mode="none"` leaves content untouched
- Test original session object is not mutated (deep copy verification)
- Test redaction summary counts are accurate
- Test redaction across all text fields (`notes`, `command_output`, `answer`, `scratchpad`, `outcome_notes`, `next_steps`)
- Test edge cases: empty strings, no matches, overlapping patterns
### Frontend
`npm run build` validates types. No new component tests needed for a checkbox toggle.
---
## Files to Create/Modify
| Action | File |
|--------|------|
| Create | `backend/app/services/redaction_service.py` |
| Modify | `backend/app/schemas/session.py` |
| Modify | `backend/app/api/endpoints/sessions.py` |
| Modify | `frontend/src/types/session.ts` |
| Modify | `frontend/src/components/session/ExportPreviewModal.tsx` |
| Modify | `frontend/src/pages/SessionDetailPage.tsx` |
| Extend | `backend/tests/test_psa_export.py` |

View File

@@ -0,0 +1,520 @@
# 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

@@ -0,0 +1,489 @@
# 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

@@ -0,0 +1,228 @@
# 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

@@ -0,0 +1,427 @@
# 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

@@ -0,0 +1,285 @@
# 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

@@ -0,0 +1,173 @@
# 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

@@ -0,0 +1,253 @@
# 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

@@ -0,0 +1,390 @@
# 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

@@ -0,0 +1,239 @@
# 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

@@ -0,0 +1,74 @@
# 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

@@ -0,0 +1,450 @@
# 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

@@ -0,0 +1,121 @@
# 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

@@ -0,0 +1,898 @@
# 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

@@ -0,0 +1,57 @@
# 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,194 @@
# UX Improvements — Implementation Plan (Merged)
## Context
Five frontend UX improvements for tree navigation, my-trees, and app layout. Changes are primarily frontend with minimal backend updates for session rewind tracking. Single branch: `feat/ux-improvements`.
## Locked Decisions
- Breadcrumb rewind **persists immediately** via API call.
- Rewound steps are **soft-abandoned** (history preserved via rewind markers, never deleted).
- Shortcuts modal is **global from AppLayout** and **route-aware**.
- Follow existing **monochrome design system** with subtle keycap/hint styling.
- `sessions.decisions` is JSONB — no DB migration needed for rewind marker fields.
---
## Step 1: Command Copy UX (Quick Win)
**Files:** `TreeNavigationPage.tsx`
### Changes
- Add a copy-to-clipboard button next to every command block:
- Action node commands (`currentNode.commands`)
- Custom step commands (`currentCustomStep.step_data.content.commands`)
- Command output textarea (when content exists)
- Button styling: `opacity-0 group-hover:opacity-100` with `Clipboard` icon, positioned `absolute top-1.5 right-1.5` inside a `group relative` wrapper.
- On click: `navigator.clipboard.writeText(text)`, swap icon to `Check` for 2 seconds.
- Track copied state with `copiedCommand: string | null`.
- Show toast only on copy failure to avoid noisy UX on success.
- **Refactor** duplicate command block rendering into a single local render helper to keep behavior consistent across action/custom-step sections.
---
## Step 2: Keyboard Hints + Global Shortcuts Modal (Quick Win)
**Files:** `AppLayout.tsx`, `TreeNavigationPage.tsx`, new `shortcutCatalog.ts`
### In-Page Hints (TreeNavigationPage.tsx)
- Add keycap-style badges (`[1]`, `[2]`, etc.) next to decision option buttons. Style: `kbd` look, lower contrast, subtle.
- Add `[Esc]` hint next to the Back button label.
### Global Shortcuts Modal (AppLayout.tsx)
- Add `?` icon button in the app header (desktop + mobile drawer).
- Opens a modal (reuse existing `Modal` component).
- Modal is **route-aware** using `location.pathname` via a `shortcutCatalog.ts` file:
- **Tree Navigation routes:** `1-9` Select option, `Esc` Go back, `Enter` Continue.
- **Tree Editor routes:** `Ctrl+S` Save, `Ctrl+Z` Undo, `Ctrl+Shift+Z` Redo.
- **Other routes:** Minimal generic navigation shortcuts.
- **Do NOT bind `?` as a keyboard shortcut** — it conflicts with text inputs. The modal is click-driven only.
### Keyboard Listener (TreeNavigationPage.tsx)
- Add `useEffect` keydown listener on `document`.
- Keys `1-9`: If current node is a decision node with options, call `handleSelectOption` for the corresponding option (index = key - 1).
- `Escape`: Trigger back/rewind navigation (same handler as breadcrumb rewind — see Step 3).
- Guards:
- Only active when `currentNode?.type === 'decision'` and options exist.
- Ignore if `selectingOption` is truthy (loading state).
- Ignore if any modal is open.
- Cleanup: Return removal function for the listener.
---
## Step 3: Interactive Breadcrumbs with Soft-Abandon Rewind (Medium)
**Files:** `TreeNavigationPage.tsx`, `session.py`, `session_to_tree.py`, `export_service.py`, `SessionDetailPage.tsx`, `SessionHistoryPage.tsx`
### Public Interface Changes
Extend `DecisionRecord` in `session.py` and `session.ts` with optional fields:
```
decision_kind?: 'step' | 'rewind_marker' (default 'step')
rewind_source?: 'breadcrumb' | 'back_button'
rewind_from_node_id?: string
rewind_to_node_id?: string
abandoned_path_segment?: string[]
```
No DB migration required — `sessions.decisions` is JSONB and stores these optional fields directly.
### Frontend Changes (TreeNavigationPage.tsx)
- Replace static breadcrumb `<span>` elements with `<button>` for all non-current crumbs.
- Styling: `text-white/40 hover:text-white/70 hover:underline cursor-pointer` for clickable items. Current item remains `font-medium text-white` as a `<span>`.
- Introduce a **unified rewind handler** used by both:
- Breadcrumb click
- Back button / `Esc` key
- **Rewind algorithm:**
1. Compute `newPath = pathTaken.slice(0, targetIndex + 1)`.
2. Append a `rewind_marker` decision with: `decision_kind`, `rewind_source`, `rewind_from_node_id`, `rewind_to_node_id`, `abandoned_path_segment` (nodes removed from active path), and `timestamp`.
3. Persist immediately via `sessionsApi.update(session.id, { path_taken: newPath, decisions: newDecisions })`.
4. On API failure: restore previous local state and show error toast.
- Clear any custom step state if active (`customStepFlow.setCurrentCustomStep(null)`).
- Session continues accumulating steps naturally from the jumped-to node.
### Backend/Downstream Compatibility
- `save_as_tree` conversion: **Ignore** records where `decision_kind === 'rewind_marker'`.
- Export generators (Markdown, Text, HTML): **Skip** rewind marker records in step numbering and output.
- Session detail/timeline pages: **Display** rewind markers as explicit timeline events (e.g., "Rewound from Step 6 to Step 3").
- Session "decision count" in `SessionHistoryPage.tsx`: **Exclude** rewind markers from the count.
---
## Step 4: Prominent "Create Tree" CTA (Quick Win)
**Files:** `MyTreesPage.tsx`
### Header Update
- Convert header to flex row with action button:
- Left: Title + subtitle.
- Right: "Create Tree" button with `Plus` icon, linking to `/trees/new`.
- Permission-aware: Only show for users with create permission (use existing role checks / `usePermissions` hook). Viewers cannot see it.
### Empty State Update
- Replace single "Browse Trees" link with two actions:
- **Primary** (white bg, higher visual weight): "Browse Library" → links to `/trees`.
- **Secondary** (outline/border style): "Create from Scratch" with `Plus` icon → links to `/trees/new`.
- "Create from Scratch" is also permission-gated.
---
## Step 5: Timer Visibility + Action Loading Feedback (Quick Win)
**Files:** `TreeNavigationPage.tsx`
### Timer Enhancement
- Change timer from plain `text-white/40` to a pill/badge style:
```
rounded-full bg-white/10 px-2.5 py-0.5 text-sm text-white/60
```
- Slightly larger icon: `h-4 w-4` (from `h-3.5 w-3.5`).
- More visible but still secondary to tree name.
### Action Loading Feedback
- Add `selectingOption: string | null` state (stores option ID being selected).
- When an option is being processed:
- The active option button shows an inline spinner replacing the number badge.
- All other option buttons get `opacity-50 pointer-events-none`.
- **Keyboard shortcuts are disabled** to prevent double-submits.
- Add similar feedback for "Continue" button with a "Continuing..." label.
- Preserve existing `isCompleting` behavior for completion flow.
---
## Files Modified Summary
| File | Steps |
|------|-------|
| `TreeNavigationPage.tsx` | 1, 2, 3, 5 |
| `MyTreesPage.tsx` | 4 |
| `AppLayout.tsx` | 2 (global shortcuts modal + `?` button) |
| `shortcutCatalog.ts` (new) | 2 (route → shortcut definitions) |
| `session.py` | 3 (accept rewind marker fields in decisions) |
| `session_to_tree.py` | 3 (ignore rewind markers) |
| `export_service.py` | 3 (skip rewind markers in export output) |
| `SessionDetailPage.tsx` | 3 (display rewind events in timeline) |
| `SessionHistoryPage.tsx` | 3 (exclude rewind markers from decision count) |
---
## Verification
### Build
```bash
cd frontend && npm run build # clean build, no type errors
cd backend && pytest --override-ini="addopts="
```
### Manual Testing
- [ ] Copy button appears on hover over all command blocks; copies correctly; shows check feedback; failure shows toast.
- [ ] Keycap hints `[1]`, `[2]`, etc. visible next to decision options; `[Esc]` visible near back button.
- [ ] Number keys 1-9 select decision options; Esc triggers rewind; shortcuts disabled during loading.
- [ ] `?` icon in app header opens global shortcuts modal with route-aware content.
- [ ] `?` is NOT bound as a keyboard shortcut (no conflict with text inputs).
- [ ] Clicking a previous breadcrumb rewinds to that point, persists immediately, and adds a rewind marker to session decisions.
- [ ] Back button uses the same rewind marker flow as breadcrumbs.
- [ ] Session detail page shows rewind events in timeline; decision count excludes rewind markers.
- [ ] Exports (Markdown, Text, HTML) skip rewind markers and render only actual steps.
- [ ] `save_as_tree` ignores rewind markers.
- [ ] Timer displays as a visible pill badge.
- [ ] Option/continue buttons show spinner + disable siblings during loading.
- [ ] MyTreesPage header shows "Create Tree" button (hidden for viewers).
- [ ] Empty state shows both "Browse Library" (primary) and "Create from Scratch" (secondary, permission-gated).

View File

@@ -0,0 +1,311 @@
# Phase C: Sensitive Data Redaction — Consolidated Implementation Plan
> **Status:** Approved — ready for implementation
> **Spec:** `docs/plans/2026-02-13-EXPORT-IMPROVEMENTS-SPEC.md` section C1
> **UI Decision:** Simple toggle (Option 1)
> **Redaction Posture:** Conservative (false positives > false negatives)
> **Branch:** `feat/export-phase-c`
> **No DB migration required**
---
## Overview
Server-side regex redaction with a simple checkbox toggle in the export preview modal. Redaction runs **after** export generation and variable resolution to ensure no sensitive data slips through via late substitution. No rich editor — keeps the existing textarea. User sees a summary of what was masked and can manually edit the result.
Redaction is **non-persistent** and **request-scoped** — database records are never mutated.
---
## Scope
**In scope:**
- Redaction for exported content in SessionDetailPage preview/download/copy flows
- Backend redaction summary returned to frontend for user visibility
- Conservative pattern set (IPv4, IPv6, email, bearer/API/JWT-like tokens, UNC paths)
**Out of scope:**
- Rich editor / highlight / per-item unmask controls
- Redaction changes to non-export APIs or persisted session data
- Hostname masking (MSP tickets legitimately reference hostnames)
---
## Design Decisions
| Decision | Rationale |
|----------|-----------|
| Redaction runs post-generation, post-variable-substitution | Prevents misses from late substitutions; redacts the final rendered text |
| Fail-closed on error | If `redaction_mode="mask"` and redaction processing fails, return 500 — never leak unredacted content |
| Conservative detection | Prefer false positives over false negatives; users can manually edit |
| Idempotent output | Running redaction twice on already-redacted content produces the same result |
| Deterministic replacement order | Patterns applied in fixed order to prevent overlapping-match inconsistencies |
| Non-persistent | DB records are never mutated; redaction is request-scoped |
| Hostname exclusion | MSP tickets legitimately reference hostnames |
---
## Backend
### 1. New File: `backend/app/services/redaction_service.py`
**`RedactionSummary` dataclass:**
```python
@dataclass
class RedactionSummary:
ips: int = 0
emails: int = 0
tokens: int = 0
unc_paths: int = 0
@property
def total(self) -> int:
return self.ips + self.emails + self.tokens + self.unc_paths
```
**Compiled regex pattern registry (deterministic order):**
| Priority | Pattern | Regex | Replacement |
|----------|---------|-------|-------------|
| 1 | Bearer tokens | `Bearer\s+[A-Za-z0-9._-]+` | `[TOKEN REDACTED]` |
| 2 | API key patterns | Long hex/base64 strings (32+ chars) | `[TOKEN REDACTED]` |
| 3 | UNC paths | `\\\\[\w.-]+\\[\w$.-]+` | `[UNC PATH REDACTED]` |
| 4 | Email | `\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z\|a-z]{2,}\b` | `[EMAIL REDACTED]` |
| 5 | IPv6 | `\b(?:[0-9a-fA-F]{1,4}:){2,7}[0-9a-fA-F]{1,4}\b` | `[IP REDACTED]` |
| 6 | IPv4 | `\b(?:\d{1,3}\.){3}\d{1,3}\b` | `[IP REDACTED]` |
> **Priority rationale:** More specific/longer patterns match first to prevent partial matches. Bearer tokens before general tokens, IPv6 before IPv4, etc.
**Core function:**
```python
def apply_redaction_to_text(content: str) -> tuple[str, RedactionSummary]:
"""
Apply all redaction patterns to text content.
Uses re.subn for replacement + counting in one pass per pattern.
Returns (redacted_content, summary).
"""
```
- Compile all patterns at module load time (not per-request)
- Use `re.subn()` for simultaneous replacement and counting
- Ensure idempotent output — already-redacted placeholders like `[IP REDACTED]` must not be re-matched
- Raise exception on unexpected errors (fail-closed behavior enforced by caller)
### 2. Schema Change: `backend/app/schemas/session.py`
Add to `SessionExport`:
```python
redaction_mode: Literal["none", "mask"] = "none"
```
### 3. Endpoint Integration: `backend/app/api/endpoints/sessions.py`
Update export flow with this execution order:
```
1. Fetch session
2. Generate export by format (markdown/text/html)
3. Resolve variables
4. IF redaction_mode == "mask":
Call redaction service on final rendered content
If redaction raises → return 500 (fail-closed)
5. Set response headers
6. Return content
```
**Critical: Redaction happens AFTER steps 2-3**, not before format branching.
**Response headers (always set):**
- `X-Redaction-Mode: none|mask` — always present on export responses
- `X-Redaction-Summary: {"ips": 3, "emails": 2, "tokens": 1, "unc_paths": 0, "total": 6}` — present only when mode is `mask`
**Redaction footer appended to export content when matches exist:**
```
--- Redacted: 3 IPs, 2 emails, 1 token ---
```
Keep existing media types and exported-flag behavior unchanged.
### 4. CORS Header Exposure: `backend/main.py`
Update **both** CORS middleware branches to expose redaction headers:
```python
expose_headers=[
"X-Redaction-Mode",
"X-Redaction-Summary",
"X-Correlation-ID",
"X-Process-Time"
]
```
> **Without this, the frontend cannot read custom headers from the response.** This is a browser security restriction (CORS).
---
## Frontend
### 5. Types: `frontend/src/types/session.ts`
Add to `SessionExport` type:
```typescript
redaction_mode?: 'none' | 'mask';
```
Add new interface:
```typescript
interface RedactionSummary {
ips: number;
emails: number;
tokens: number;
unc_paths: number;
total: number;
}
```
### 6. API Layer: `frontend/src/api/sessions.ts`
**Keep existing `export()` function unchanged** for backward compatibility.
**Add new function:**
```typescript
async function exportWithMeta(
id: string,
options: SessionExport
): Promise<{
content: string;
redactionMode: 'none' | 'mask';
redactionSummary: RedactionSummary | null;
}> {
// Makes same API call but parses response headers
// Safely parse X-Redaction-Summary with try/catch
// Returns structured metadata alongside content
}
```
> **Why a separate function?** Existing callers of `export()` don't break. Preview flows that need metadata use the new function. Clean separation.
### 7. Session Detail Page: `frontend/src/pages/SessionDetailPage.tsx`
- Add state: `redactionMode: 'none' | 'mask'` (default: `'none'`)
- Add state: `redactionSummary: RedactionSummary | null`
- Use `exportWithMeta()` for preview and toggle-refresh flows
- Pass toggle callback and summary to `ExportPreviewModal`
- Keep "Copy for Ticket" and non-preview copy behavior unchanged unless explicitly toggled
- Follow same pattern as existing `includeSummary` state
### 8. Export Preview Modal: `frontend/src/components/session/ExportPreviewModal.tsx`
**New props:**
```typescript
redactionEnabled?: boolean;
onToggleRedaction?: (enabled: boolean) => void;
redactionSummary?: RedactionSummary | null;
```
**Checkbox — match existing "Include Summary" visual pattern:**
```tsx
<label className="flex items-center gap-2 text-sm text-white/60 cursor-pointer">
<input type="checkbox" checked={redactionEnabled} onChange={...} />
Mask Sensitive Data
</label>
```
**Summary display:**
- When matches exist: `"Masked: 3 IPs, 2 emails, 1 token"` in `text-blue-400`
- When mask is on but no matches: `"No sensitive data detected"` in `text-white/40`
- Helper text below toggle: `"Toggling reloads content and replaces any manual edits"` in `text-white/30 text-xs`
---
## Testing
### Backend Unit Tests: `backend/tests/test_redaction_service.py`
| Test Case | Description |
|-----------|-------------|
| Individual patterns | Each pattern type independently (IPv4, IPv6, email, bearer token, API key, UNC path) |
| Mixed content | Multiple pattern types in single text block, verify aggregate counts |
| No matches | Input with no sensitive data returns unchanged text and zero counts |
| Idempotency | Already-redacted placeholders (`[IP REDACTED]`) are not re-matched or double-counted |
| Token boundaries | Conservative token detection minimum-length boundaries (32+ chars) |
| Edge cases | Empty strings, None handling, very long strings |
| Total calculation | `summary.total` matches sum of individual counts |
### Backend Integration Tests: `backend/tests/test_sessions.py` (extend)
| Test Case | Description |
|-----------|-------------|
| `redaction_mode=none` | Returns unmasked export and `X-Redaction-Mode: none` header |
| `redaction_mode=mask` | Masks content and sets parseable `X-Redaction-Summary` header |
| Variable substitution | Content from variable resolution is also masked when matching patterns |
| Media types unchanged | Export content types remain the same regardless of redaction |
| Exported flag unchanged | Existing exported-flag semantics for completed/in-progress sessions unchanged |
| Error behavior | Redaction failure returns 500, not unredacted content |
### Frontend Validation
- `npm run build` validates types
- `npm run test` for any existing test suites
- Verify `exportWithMeta` header parsing behavior
- Verify `ExportPreviewModal` toggle and summary rendering states
### Manual QA Checklist
- [ ] Preview with redaction OFF shows original content
- [ ] Preview with redaction ON masks sensitive data and shows accurate summary
- [ ] Toggle redaction repeatedly — verify stable counts and content
- [ ] Download from preview uses the currently shown (edited/masked) content
- [ ] Copy for Ticket respects current redaction choice
- [ ] Content with variables resolves correctly, then redacts
- [ ] Redaction footer appears in exported content when matches exist
- [ ] Summary line disappears when redaction is toggled off
---
## Acceptance Criteria
1. User can enable/disable masking via preview toggle without page reload
2. Masked output contains no raw matches for any covered pattern
3. Summary counts are visible in UI and match backend-calculated values
4. No persisted session fields are changed by export redaction
5. Existing export formats and Phase B features continue to pass current tests
6. Redaction failure results in 500 error, never unredacted content delivery
---
## Files to Create/Modify
| Action | File | Notes |
|--------|------|-------|
| **Create** | `backend/app/services/redaction_service.py` | Core redaction engine |
| **Create** | `backend/tests/test_redaction_service.py` | Unit tests for redaction |
| **Modify** | `backend/app/schemas/session.py` | Add `redaction_mode` to `SessionExport` |
| **Modify** | `backend/app/api/endpoints/sessions.py` | Integration point (post-generation) |
| **Modify** | `backend/main.py` | CORS `expose_headers` for both branches |
| **Modify** | `frontend/src/types/session.ts` | Add `RedactionSummary` interface + `redaction_mode` |
| **Modify** | `frontend/src/api/sessions.ts` | Add `exportWithMeta()` function |
| **Modify** | `frontend/src/components/session/ExportPreviewModal.tsx` | Checkbox + summary UI |
| **Modify** | `frontend/src/pages/SessionDetailPage.tsx` | State management + wiring |
| **Extend** | `backend/tests/test_sessions.py` | Integration tests for export + redaction |
---
## Implementation Order
1. `redaction_service.py` + unit tests (standalone, no dependencies)
2. Schema change in `session.py`
3. Endpoint integration in `sessions.py` + CORS update in `main.py`
4. Backend integration tests
5. Frontend types + API layer (`session.ts`, `sessions.ts`)
6. Frontend UI (`ExportPreviewModal.tsx`, `SessionDetailPage.tsx`)
7. Manual QA against checklist
---
## Assumptions & Defaults
- Default redaction mode is `none`
- Redaction scope is export content only, not stored session data
- Hostnames are intentionally not masked
- Conservative detection is accepted, including possible false positives
- No DB migration is required
- Existing `export()` API function remains unchanged for backward compatibility

View File

@@ -0,0 +1,465 @@
# Procedural Flows — Unified Implementation Plan
## Plan Comparison & Analysis
Before diving into the merged plan, here's how the three source plans compared and why specific choices were made.
### Plan 1 (Design Document — Claude + Michael)
**Strengths:** Richest feature vision. Best product thinking — pre-flight forms with field grouping, rich step types (action/informational/verification/warning), PowerShell snippets with variable injection, section headers within procedures, verification checks with multiple types (checkbox/text/screenshot), time estimates per step, and a strong example template library (DC Build, M365 Onboarding). Excellent as a product requirements document.
**Weaknesses:** Created entirely new database tables (ProcedureTemplate, FormField, ProcedureStep, ProcedureInstance) rather than extending the existing Tree model. This would duplicate a huge amount of existing infrastructure — authentication, CRUD endpoints, export pipelines, session management — all of which already work. No awareness of the existing codebase's file structure, variable resolution system (`[VAR:name]`), Zustand stores, or migration numbering. Not directly actionable by Claude Code without significant translation.
**Verdict:** Best product vision, weakest implementation strategy.
---
### Plan 2 (Procedural Flow Framework v1)
**Strengths:** Strong architectural awareness — adds `flow_mode` to the existing Tree model rather than creating parallel tables. Introduces dedicated procedural node types (`procedure_step`, `procedure_end`) which gives clean separation. Thorough validation rules (acyclic graph, single end node, no branching). Good test case coverage. Handles markdown parser/serializer compatibility (code-mode). Warning-only policy for missing required fields is pragmatic.
**Weaknesses:** Introduces new node types which adds complexity to the existing node system — every component that switches on node type needs updating. Six implementation phases is a lot of surface area. The intake form is "warning-only" for required fields, which could let techs start procedures with missing critical data (like no IP address for a DC build). Doesn't specify the runtime UX in much detail — no two-panel layout, no progress tracking specifics. Doesn't reference specific existing files or infrastructure to reuse.
**Verdict:** Strongest validation and publish constraints, but over-engineered node type approach.
---
### Plan 3 (Implementation Plan)
**Strengths:** Most implementation-ready by far. References exact file paths, existing infrastructure (variable_service.py, variableResolver.ts, session_variables JSONB field from migration 028, treeEditorStore patterns), and specific migration numbering (035). Uses `tree_type` on the existing Tree model — minimal schema changes. Creates a separate `proceduralEditorStore.ts` (clean separation) while reusing existing patterns. The two-panel navigation UX (step checklist + step detail) is concrete and buildable. Flat step array is simpler than a node graph for linear procedures. Four focused phases instead of six. The file manifest table makes it crystal clear what gets created vs modified.
**Weaknesses:** Lighter on product features — no step types (action/warning/verification), no PowerShell snippet support, no time estimates, no section headers, no verification checks. Uses `[VAR:name]` syntax (existing) rather than `{{variable}}` (more intuitive but would require a new resolver). Doesn't include template seeding in its phases. Less detail on the intake form field types compared to Plans 1 and 2.
**Verdict:** Highest chance of successful implementation, needs feature enrichment from Plans 1 and 2.
---
### Summary Matrix
| Criteria | Plan 1 (Design Doc) | Plan 2 (Framework v1) | Plan 3 (Impl Plan) |
|---|---|---|---|
| Codebase awareness | ❌ None | ⚠️ Partial | ✅ Exact file paths |
| Reuses existing infra | ❌ New tables | ✅ Extends Tree model | ✅ Extends Tree + reuses variable system |
| Feature richness | ✅ Richest | ⚠️ Medium | ⚠️ Lean |
| UX detail | ✅ Runner + Builder + Dashboard | ⚠️ Runtime mentioned | ✅ Two-panel layout specified |
| Implementation clarity | ❌ Conceptual | ⚠️ Phased but abstract | ✅ File-level specificity |
| Validation rules | ⚠️ Basic | ✅ Thorough | ⚠️ Basic |
| Chance of success | ⚠️ Low (too much new) | ⚠️ Medium (complex) | ✅ High (incremental) |
| Claude Code ready | ❌ No | ⚠️ Partially | ✅ Yes |
### Merge Strategy
**Use Plan 3 as the structural backbone** (file paths, migration approach, store patterns, reuse strategy), **enrich with Plan 1's product features** (rich step types, PowerShell snippets, verification checks, time estimates, section headers, template examples), and **incorporate Plan 2's validation rules** (publish constraints, acyclic enforcement, explicit end node requirement).
---
## Unified Implementation Plan
### Architecture Decision
Add a `tree_type` enum field (`'troubleshooting' | 'procedural'`) to the existing Tree model. Same table, same API endpoints, different behavior based on type. Procedural flows store steps as a flat ordered array (not a node graph) and include an intake form schema for pre-flight data collection. This avoids duplicating the entire tree infrastructure while giving procedural flows their own editor, navigation, and validation.
### Variable Syntax
Use the existing `[VAR:name]` infrastructure from `variable_service.py` and `variableResolver.ts`. This is already battle-tested in the codebase. No need to introduce `{{variable}}` syntax and a parallel resolver.
### Scope
**In scope:**
- `tree_type` + `intake_form` fields on Tree model (migration 035)
- Procedural tree validation (linear steps only, no decision nodes, explicit end)
- Rich step model (types, warnings, time estimates, verification, PowerShell, section headers)
- Intake form builder in the editor with field validation
- Intake form modal before session start
- Two-panel procedural navigation (step checklist + step detail)
- Variable resolution in step content via existing `[VAR:name]` infrastructure
- Procedural-aware exports
- Dashboard tabs (Troubleshooting | Procedures)
- Two seeded templates (DC Build, M365 User Onboarding)
**Out of scope (deferred):**
- Conditional steps (steps that show/hide based on form values)
- Procedural code-mode editor
- Step templates / reusable step library
- Approval workflows
- Screenshot verification type
- Session assignment/handoff
- AI-assisted template generation
- Automated PowerShell execution
---
## Data Model
### Migration 035: `035_add_tree_type_and_intake_form.py`
```
tree_type: String(20), NOT NULL, server_default='troubleshooting'
- Check constraint: IN ('troubleshooting', 'procedural')
- Indexed for filtering
intake_form: JSONB, nullable, default NULL
- Stores the intake form field definitions (see IntakeFormField schema below)
```
Existing trees automatically get `tree_type='troubleshooting'` and `intake_form=NULL`. No data migration needed.
### Intake Form Field Schema
Each field in the `intake_form` JSONB array supports:
| Property | Type | Required | Description |
|---|---|---|---|
| `variable_name` | string | Yes | Variable key used in `[VAR:name]` tokens. Pattern: `^[a-z][a-z0-9_]*$` |
| `label` | string | Yes | Display label (e.g., "Server Name") |
| `field_type` | enum | Yes | `text`, `textarea`, `number`, `ip_address`, `email`, `select`, `multi_select`, `checkbox`, `password` |
| `required` | boolean | Yes | Whether the field must be filled before starting |
| `options` | string[] | Conditional | Required for `select` and `multi_select` types |
| `placeholder` | string | No | Hint text shown in empty field |
| `help_text` | string | No | Tooltip or description explaining the field |
| `default_value` | string | No | Pre-filled default |
| `group_name` | string | No | Section grouping header (e.g., "Network Settings") |
| `display_order` | integer | Yes | Position in form (1, 2, 3...) |
| `validation` | object | No | Validation config (see below) |
#### Field Validation Config
```json
{
"min_length": 1,
"max_length": 15,
"pattern": "^[A-Za-z0-9-]+$",
"pattern_message": "Only letters, numbers, and hyphens allowed",
"format": "ipv4",
"min_value": 1,
"max_value": 4094,
"min_selections": 1,
"max_selections": 5
}
```
Format validators by field type:
- `ip_address` → validates IPv4 format (e.g., `192.168.1.10`)
- `email` → validates email format
- `number` → validates numeric range
- `text` → validates length and optional regex pattern
- `password` → validates minimum length
- `select` → value must match one of the defined options
- `multi_select` → values must match defined options, respects min/max selections
### Procedural Step Schema
Procedural trees store steps as a flat ordered array in the tree's node structure. Each step has:
| Property | Type | Required | Description |
|---|---|---|---|
| `id` | string | Yes | Unique step identifier |
| `type` | enum | Yes | `procedure_step` or `procedure_end` |
| `step_number` | integer | Yes | Order position (1, 2, 3...) |
| `title` | string | Yes | Step title (e.g., "Configure Static IP Address") |
| `description` | string | Yes | Instruction text with `[VAR:name]` placeholders |
| `content_type` | enum | Yes | `action`, `informational`, `verification`, `warning` |
| `estimated_minutes` | integer | No | Time estimate for this step |
| `warning_text` | string | No | Caution/warning banner text |
| `verification_prompt` | string | No | What to verify before marking complete |
| `verification_type` | enum | No | `checkbox` (confirm done) or `text_input` (enter observed value) |
| `commands` | string | No | PowerShell/CLI command block with `[VAR:name]` support and copy-to-clipboard |
| `expected_outcome` | string | No | What should happen after executing the step |
| `notes_enabled` | boolean | Yes | Whether tech can add freeform notes (default: true) |
| `section_header` | string | No | Section divider text (e.g., "Phase 2: AD Configuration") |
| `reference_url` | string | No | Link to external documentation |
| `next_node_id` | string | Conditional | Required for `procedure_step`, points to next step. Not present on `procedure_end`. |
### Procedural Publish Validation Rules
When publishing a procedural tree, enforce:
1. Tree must contain at least one `procedure_step` and exactly one `procedure_end`
2. Only `procedure_step` and `procedure_end` node types are allowed (no decision nodes)
3. No branching — each `procedure_step` points to exactly one `next_node_id`
4. Graph must be acyclic (no loops)
5. All steps must be reachable from the root node
6. The chain must terminate at the single `procedure_end` node
7. No orphan nodes
8. If `intake_form` is defined, all `variable_name` values must be unique
9. `select` and `multi_select` fields must have at least one option defined
### Session Variables
`SessionCreate` accepts optional `session_variables: dict[str, str]` to store intake form values. These are persisted in the existing `session_variables` JSONB field (already exists from migration 028).
At session start for procedural trees:
- If `intake_form` exists → validate required fields are present
- Required fields that are missing → **block start** (not warning-only; these are critical operational data)
- Optional fields that are missing → allowed, unresolved `[VAR:name]` tokens render as highlighted placeholders: `[server_name — not provided]`
---
## Phase 1: Backend Foundation
### Files to Modify
| File | Changes |
|---|---|
| `backend/alembic/versions/035_add_tree_type_and_intake_form.py` | **CREATE** — Migration adding `tree_type` and `intake_form` columns |
| `backend/app/models/tree.py` | Add `tree_type` and `intake_form` mapped columns |
| `backend/app/schemas/tree.py` | Add `IntakeFormField` schema, field type enum, validation schema. Add `tree_type` + `intake_form` to `TreeCreate`, `TreeUpdate`, `TreeResponse`, `TreeListResponse`. Add `session_variables` to `SessionCreate`. |
| `backend/app/core/tree_validation.py` | Add `validate_procedural_structure()` — enforces all publish rules (linear chain, single end, no branching, no cycles, no orphans). Update `can_publish_tree()` to dispatch by `tree_type`. |
| `backend/app/api/endpoints/sessions.py` | `start_session`: accept `session_variables`, validate required intake fields for procedural trees, store in session. Include `tree_type` in `tree_snapshot`. |
| `backend/app/api/endpoints/trees.py` | Add optional `tree_type` query filter to tree listing. Ensure fork copies `tree_type` + `intake_form`. |
### Tests
- Existing trees default to `tree_type='troubleshooting'` and `intake_form=NULL`
- Create/update/list/filter by `tree_type`
- Procedural publish rejected when: branching exists, cycles exist, missing end node, multiple end nodes, orphan nodes
- Procedural publish accepted for valid linear chain
- Session start with `session_variables` populated from intake
- Required intake fields missing → session start blocked
- Optional fields missing → session starts, variables unresolved
- Fork preserves `tree_type` and `intake_form`
- All existing troubleshooting tests still pass
### Deliverable
A procedural tree can be created, validated, published, and started via API with intake form variables stored in the session.
---
## Phase 2: Procedural Editor (Frontend)
### Files to Create
| File | Purpose |
|---|---|
| `frontend/src/store/proceduralEditorStore.ts` | New Zustand store (immer + zundo for undo/redo). Flat step array operations: add, remove, update, reorder. Intake form field operations: add, remove, update, reorder. `getTreeForSave()` builds the API payload. Follows patterns from `treeEditorStore.ts`. |
| `frontend/src/pages/ProceduralEditorPage.tsx` | Route: `/flows/new?type=procedural` and `/flows/:id/edit` (when tree_type=procedural). Three sections: metadata (name, description, category, tags), intake form builder, step list editor. |
| `frontend/src/components/procedural-editor/IntakeFormBuilder.tsx` | Add/remove/reorder form field definitions. Drag-to-reorder via existing DnD library. Live preview panel showing how the form will appear to techs. |
| `frontend/src/components/procedural-editor/IntakeFieldEditor.tsx` | Single field configuration: label, variable_name, field_type, required toggle, options list (for select/multi_select), placeholder, help_text, default_value, group_name, validation rules. |
| `frontend/src/components/procedural-editor/StepList.tsx` | Ordered step list with drag handles. Inline title editing. Section header support — steps grouped under optional section dividers. Step type badges (action/verification/warning/informational). |
| `frontend/src/components/procedural-editor/StepEditor.tsx` | Expanded step editing: title, content_type selector, description with markdown support, warning_text, estimated_minutes, verification_prompt + verification_type, commands (PowerShell block), expected_outcome, reference_url, notes_enabled toggle, section_header. Highlights `[VAR:name]` tokens and shows available variables from intake form. |
### Files to Modify
| File | Changes |
|---|---|
| `frontend/src/types/tree.ts` | Add `TreeType = 'troubleshooting' \| 'procedural'`. Add `IntakeFormField`, `IntakeFieldValidation`, `ProceduralStep`, `StepContentType` interfaces. Add `tree_type` + `intake_form` to `Tree`, `TreeListItem`, `TreeCreate`. |
| `frontend/src/pages/MyTreesPage.tsx` | "New" button offers type choice (troubleshooting vs procedural). Route to appropriate editor. |
| `frontend/src/pages/TreeLibraryPage.tsx` | Add tab filter by `tree_type`. Different icon for procedural flows (`ListOrdered` or similar). |
| `frontend/src/router.tsx` | Add routes for `ProceduralEditorPage` and `ProceduralNavigationPage`. |
### Deliverable
Admins/editors can create and edit procedural flow templates with intake forms and rich steps through a dedicated editor UI. Dashboard shows procedures in a filtered view.
---
## Phase 3: Procedural Navigation & Runtime (Frontend)
### Files to Create
| File | Purpose |
|---|---|
| `frontend/src/pages/ProceduralNavigationPage.tsx` | Route: `/flows/:id/navigate`. Two-panel layout: step checklist (left sidebar, ~280px) + step detail (right). Progress bar: "Step X of Y" with percentage. "Mark Complete & Next" button. Running time tracker (elapsed vs estimated). Collapsible sidebar showing intake form data for reference. |
| `frontend/src/components/procedural/IntakeFormModal.tsx` | Modal rendered before session start. Renders form from `intake_form` definitions with proper input types, validation, and grouping by `group_name`. Required fields enforced. Validates on submit. |
| `frontend/src/components/procedural/StepChecklist.tsx` | Left panel: numbered list, completed checkmarks, current step highlighted, section header dividers. Click to jump to any step. |
| `frontend/src/components/procedural/StepDetail.tsx` | Right panel: step title + number, content_type indicator badge, resolved description (variables injected), warning banner (if warning_text exists), commands block with copy-to-clipboard, expected outcome, verification prompt + input, notes field, reference link, estimated time badge. |
| `frontend/src/components/procedural/ProgressBar.tsx` | Step count + percentage bar + elapsed time vs total estimated time. |
| `frontend/src/components/procedural/CompletionSummary.tsx` | Shown when all steps complete. Displays: all form data, step completion timestamps, tech notes per step, verification values, total time elapsed. This is the audit trail view. |
### Session Lifecycle
1. User clicks "Start" on a procedural flow
2. If `intake_form` exists → show `IntakeFormModal`, collect and validate values
3. Call `POST /sessions` with `session_variables` from the form
4. Navigate to step 1, render two-panel layout
5. Each step: tech reads instructions (with variables resolved), performs work, optionally adds notes, fills verification if required, clicks "Mark Complete & Next"
6. Step completion creates a `DecisionRecord` (reuses existing session update API)
7. Completing last step → `CompletionSummary` view → session marked complete
### Variable Resolution Behavior
- Resolved variables render as highlighted/bold inline text (visually distinct from surrounding content)
- Unresolved optional variables render as `[server_name — not provided]` with a muted/highlighted style
- Variables in commands blocks also resolve, so PowerShell is copy-paste ready
- Intake form data panel remains visible in a collapsible sidebar for quick reference
### Deliverable
Technicians can fill out a pre-flight form, step through a procedure with injected variables, track completion, add notes, and see a completion summary.
---
## Phase 4: Export, Polish & Template Seeding
### Export Changes
| File | Changes |
|---|---|
| `backend/app/services/export_service.py` | Detect `tree_type` from `tree_snapshot`. Procedural format: numbered checklist with completion status, step times, tech notes. Include "Project Parameters" section from `session_variables`. All 4 formats (markdown, text, html, psa). |
### Session Detail Changes
| File | Changes |
|---|---|
| `frontend/src/pages/SessionDetailPage.tsx` | Detect procedural sessions from `tree_snapshot.tree_type`. Show step checklist layout with completion data instead of decision timeline. |
### Dashboard Tab Integration
| File | Changes |
|---|---|
| `frontend/src/pages/MyTreesPage.tsx` | Add tab bar: **Troubleshooting** | **Procedures**. Each tab filters by `tree_type`. Procedure cards show: title, category, step count, estimated total time. |
| Navigation sidebar | Add dedicated "Procedures" menu item linking to the Procedures tab. |
### Template Seeding
Create two seed templates that demonstrate all features:
#### Template 1: New Domain Controller Build
**Intake Form Fields:**
| Variable | Label | Type | Required | Validation |
|---|---|---|---|---|
| `server_name` | Server Name | text | Yes | Max 15 chars, no spaces |
| `static_ip` | Static IP Address | ip_address | Yes | IPv4 format |
| `subnet_mask` | Subnet Mask | ip_address | Yes | IPv4 format |
| `default_gateway` | Default Gateway | ip_address | Yes | IPv4 format |
| `preferred_dns` | Preferred DNS Server | ip_address | Yes | IPv4 format |
| `alternate_dns` | Alternate DNS Server | ip_address | No | IPv4 format |
| `domain_name` | Domain Name (FQDN) | text | Yes | Must contain a dot |
| `netbios_name` | NetBIOS Domain Name | text | Yes | Max 15 chars |
| `dsrm_password` | DSRM Password | password | Yes | Min 12 chars |
| `server_roles` | Roles to Install | multi_select | Yes | Options: AD DS, DNS, DHCP, File Services |
| `site_name` | AD Site Name | text | No | Default: Default-First-Site-Name |
| `ticket_number` | Ticket / Change # | text | No | For documentation |
**Steps (abbreviated — full steps would include descriptions, commands, warnings, and verifications):**
| # | Section | Title | Type | Est. Time | Has Verification |
|---|---|---|---|---|---|
| 1 | OS Configuration | Rename Server to [VAR:server_name] | action | 5 min | Yes — confirm hostname |
| 2 | OS Configuration | Configure Static IP [VAR:static_ip] | action | 5 min | Yes — confirm IP set |
| 3 | OS Configuration | Install Server Roles: [VAR:server_roles] | action | 15 min | Yes — confirm roles |
| 4 | AD Installation | Promote to Domain Controller | action | 20 min | Yes — confirm reboot |
| 5 | AD Installation | Verify AD DS Service Running | verification | 5 min | Yes — service status |
| 6 | DNS Configuration | Configure DNS Forwarders | action | 5 min | No |
| 7 | DNS Configuration | Create Reverse Lookup Zone | action | 5 min | No |
| 8 | Verification | Run dcdiag /v | verification | 10 min | Yes — enter pass/fail |
| 9 | Verification | Run repadmin /replsummary | verification | 5 min | Yes — enter pass/fail |
| 10 | Cleanup | Document Completion | informational | 5 min | No |
| — | — | Procedure Complete | procedure_end | — | — |
#### Template 2: Microsoft 365 New User Onboarding
**Intake Form Fields:**
| Variable | Label | Type | Required |
|---|---|---|---|
| `first_name` | First Name | text | Yes |
| `last_name` | Last Name | text | Yes |
| `display_name` | Display Name | text | Yes |
| `email_address` | Email Address | email | Yes |
| `job_title` | Job Title | text | No |
| `department` | Department | text | No |
| `manager_email` | Manager Email | email | No |
| `license_type` | License Type | select | Yes |
| `security_groups` | Security Groups | multi_select | No |
| `distribution_lists` | Distribution Lists | multi_select | No |
| `shared_mailboxes` | Shared Mailboxes | multi_select | No |
| `temp_password` | Temporary Password | password | Yes |
**Steps:** Create user account → Assign [VAR:license_type] license → Add to security groups [VAR:security_groups] → Add to distribution lists → Configure shared mailbox access → Set manager → Verify user can sign in → Document completion → Procedure Complete (end)
### Deliverable
Procedures export cleanly with all data. Dashboard has tabs. Two real-world templates are available out of the box.
---
## Key Files Manifest
| Action | File |
|---|---|
| **CREATE** | `backend/alembic/versions/035_add_tree_type_and_intake_form.py` |
| **CREATE** | `frontend/src/store/proceduralEditorStore.ts` |
| **CREATE** | `frontend/src/pages/ProceduralEditorPage.tsx` |
| **CREATE** | `frontend/src/pages/ProceduralNavigationPage.tsx` |
| **CREATE** | `frontend/src/components/procedural-editor/IntakeFormBuilder.tsx` |
| **CREATE** | `frontend/src/components/procedural-editor/IntakeFieldEditor.tsx` |
| **CREATE** | `frontend/src/components/procedural-editor/StepList.tsx` |
| **CREATE** | `frontend/src/components/procedural-editor/StepEditor.tsx` |
| **CREATE** | `frontend/src/components/procedural/IntakeFormModal.tsx` |
| **CREATE** | `frontend/src/components/procedural/StepChecklist.tsx` |
| **CREATE** | `frontend/src/components/procedural/StepDetail.tsx` |
| **CREATE** | `frontend/src/components/procedural/ProgressBar.tsx` |
| **CREATE** | `frontend/src/components/procedural/CompletionSummary.tsx` |
| **MODIFY** | `backend/app/models/tree.py` — add `tree_type`, `intake_form` columns |
| **MODIFY** | `backend/app/schemas/tree.py` — add schemas, update CRUD schemas |
| **MODIFY** | `backend/app/core/tree_validation.py` — add procedural validation |
| **MODIFY** | `backend/app/api/endpoints/sessions.py` — accept `session_variables` |
| **MODIFY** | `backend/app/api/endpoints/trees.py` — add `tree_type` filter, fork support |
| **MODIFY** | `backend/app/services/export_service.py` — procedural export format |
| **MODIFY** | `frontend/src/types/tree.ts` — add procedural types |
| **MODIFY** | `frontend/src/router.tsx` — add procedural routes |
| **MODIFY** | `frontend/src/pages/MyTreesPage.tsx` — tabs + type selector |
| **MODIFY** | `frontend/src/pages/TreeLibraryPage.tsx` — type filter |
| **MODIFY** | `frontend/src/pages/SessionDetailPage.tsx` — procedural session view |
---
## Reusable Existing Infrastructure
These already exist and should be reused directly:
- **Variable resolution:** `backend/app/services/variable_service.py` + `frontend/src/lib/variableResolver.ts` — already handles `[VAR:name]` tokens
- **Session model:** `session_variables` JSONB field already exists (migration 028)
- **Drag and drop:** existing DnD library used in tree editor
- **Export pipeline:** redaction + variable resolution already in place, just needs procedural formatting branch
- **Zustand patterns:** `treeEditorStore.ts` as reference for `proceduralEditorStore` (immer + zundo)
- **Authentication & RBAC:** existing auth middleware, no changes needed
- **Organization scoping:** existing org/client filtering applies automatically
---
## Compatibility Notes
- Existing troubleshooting trees remain default (`tree_type='troubleshooting'`) and are completely unchanged
- Existing sessions, exports, and navigation behavior remain intact
- No migration of existing tree data required beyond adding the new columns with defaults
- All existing tests must continue to pass
---
## Verification Checklist
### Backend
- [ ] `pytest --override-ini="addopts="` — all existing + new tests pass
- [ ] Migration 035 applies cleanly and rolls back cleanly
- [ ] Existing trees get `tree_type='troubleshooting'` automatically
### Frontend
- [ ] `npm run build` — clean tsc + vite build
- [ ] All existing troubleshooting flows work identically
### Manual QA
- [ ] Create procedural flow with intake form in editor
- [ ] Publish procedural flow (validation passes for valid, rejects invalid)
- [ ] Start procedure → fill intake form → navigate all steps → complete
- [ ] Verify variables resolve correctly in step text and commands
- [ ] Verify unresolved optional variables show placeholder text
- [ ] Verify step completion tracking (checkboxes, timestamps, notes)
- [ ] Verify completion summary shows all data
- [ ] Export completed procedure in all 4 formats
- [ ] Both seeded templates run end-to-end correctly
- [ ] Dashboard tabs filter correctly
- [ ] No regressions in troubleshooting flow functionality
---
## Future Considerations (Deferred)
These are documented for future planning but are explicitly out of scope:
- **Conditional steps:** Steps that show/hide based on intake form values
- **Branching hybrid:** Mini decision-tree within a procedure step
- **AI-assisted template generation:** Use Claude API to generate templates from descriptions
- **Approval workflows:** Manager sign-off before procedure can start
- **Sub-checklists within steps:** A step containing its own checklist
- **Template marketplace:** Share templates between organizations
- **Automated execution:** Execute PowerShell commands remotely and capture output
- **Screenshot verification:** Attach image proof to verification checks
- **Code-mode editor:** Markdown-based authoring for procedural flows

View File

@@ -0,0 +1,458 @@
# 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`