diff --git a/CLAUDE.md b/CLAUDE.md index 47424eb0..fadde848 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,6 +41,10 @@ When adding new pages/components: use "ResolutionFlow" branding, monochrome desi - Custom step continuation flow refinements (Phase 2.5) - Tree forking from sessions with custom steps +### Recently Completed + +- Export improvements (Phases A-C): step cutoff, summary block, detail levels, editable preview, sensitive data redaction + --- ## Tech Stack @@ -208,6 +212,8 @@ SELECT 'tag', 'slug', NULL::uuid as team_id -- Must cast NULL to uuid **8. CORS errors can mask 500s:** Check backend logs first. Also run `alembic upgrade head` after pulling changes. +**11. CORS `expose_headers` for custom response headers:** Browsers block frontend from reading custom headers (e.g. `X-Redaction-Summary`) unless CORS middleware includes `expose_headers=["X-Custom-Header"]`. Must be set in BOTH CORS branches in `main.py`. + **9. Public endpoints with optional auth:** Use manual `_get_optional_user(request, db)` helper, NOT `Optional[User]` param (FastAPI treats it as Pydantic field). **10. React Router — Clear dirty state before navigation:** diff --git a/LESSONS-LEARNED.md b/LESSONS-LEARNED.md index e469061b..a2fcba13 100644 --- a/LESSONS-LEARNED.md +++ b/LESSONS-LEARNED.md @@ -890,6 +890,39 @@ def _fix_node_fields(node): --- +## Export / Redaction + +### CORS `expose_headers` Required for Custom Response Headers +**Problem:** Frontend reads custom response headers (e.g. `X-Redaction-Summary`) but gets `undefined` — the header exists in the response but Axios can't access it. + +**Cause:** Browsers enforce CORS restrictions on response headers. Only "CORS-safelisted" headers (Cache-Control, Content-Type, etc.) are accessible by default. Custom headers require explicit exposure. + +**Solution:** Add `expose_headers` to CORS middleware in `main.py` (both branches): +```python +app.add_middleware( + CORSMiddleware, + allow_origins=settings.allowed_origins, + expose_headers=["X-Redaction-Mode", "X-Redaction-Summary"], + ... +) +``` + +**Files affected:** `backend/app/main.py` (both CORS middleware branches) + +--- + +### Redaction Must Run AFTER Variable Resolution +**Problem:** Sensitive data injected via session variables (e.g. `{{client_ip}}` → `192.168.1.1`) would bypass redaction if it ran before variable substitution. + +**Solution:** Export pipeline order matters: +1. Generate export by format (markdown/html/text/psa) +2. Resolve session variables +3. Apply redaction (if `redaction_mode == "mask"`) + +**Key file:** `backend/app/api/endpoints/sessions.py` — the redaction block is placed after both generation and variable resolution, with fail-closed error handling (500 on failure, never return unredacted content). + +--- + ## Adding New Lessons When you encounter and fix a bug, add it here with: diff --git a/backend/alembic/versions/035_add_tree_type_and_intake_form.py b/backend/alembic/versions/035_add_tree_type_and_intake_form.py new file mode 100644 index 00000000..cafec775 --- /dev/null +++ b/backend/alembic/versions/035_add_tree_type_and_intake_form.py @@ -0,0 +1,47 @@ +"""add tree_type and intake_form to trees + +Revision ID: 035 +Revises: 034 +Create Date: 2026-02-14 + +Adds tree_type (troubleshooting/procedural) and intake_form (JSONB) columns to trees table. +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import JSONB + + +# revision identifiers, used by Alembic. +revision: str = '035' +down_revision: Union[str, None] = '034' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column('trees', sa.Column( + 'tree_type', + sa.String(20), + nullable=False, + server_default='troubleshooting', + )) + op.add_column('trees', sa.Column( + 'intake_form', + JSONB(), + nullable=True, + )) + op.create_check_constraint( + 'ck_trees_tree_type', + 'trees', + "tree_type IN ('troubleshooting', 'procedural')", + ) + op.create_index('ix_trees_tree_type', 'trees', ['tree_type']) + + +def downgrade() -> None: + op.drop_index('ix_trees_tree_type', table_name='trees') + op.drop_constraint('ck_trees_tree_type', 'trees', type_='check') + op.drop_column('trees', 'intake_form') + op.drop_column('trees', 'tree_type') diff --git a/backend/app/api/endpoints/sessions.py b/backend/app/api/endpoints/sessions.py index 481c216d..08b45875 100644 --- a/backend/app/api/endpoints/sessions.py +++ b/backend/app/api/endpoints/sessions.py @@ -136,13 +136,27 @@ async def start_session( detail="You don't have access to this tree" ) + # For procedural trees with intake forms, validate required fields + session_variables = session_data.session_variables or {} + if tree.tree_type == 'procedural' and tree.intake_form: + missing_fields = [] + for field in tree.intake_form: + if field.get("required") and not session_variables.get(field["variable_name"]): + missing_fields.append(field["label"]) + if missing_fields: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"Missing required intake form fields: {', '.join(missing_fields)}" + ) + # Create session with tree snapshot (includes tree metadata for filtering/export) tree_snapshot = { **tree.tree_structure, "name": tree.name, "description": tree.description, "category": tree.category, - "version": tree.version + "version": tree.version, + "tree_type": tree.tree_type, } new_session = Session( @@ -152,7 +166,8 @@ async def start_session( path_taken=[], decisions=[], ticket_number=session_data.ticket_number, - client_name=session_data.client_name + client_name=session_data.client_name, + session_variables=session_variables, ) # Increment tree usage count @@ -421,7 +436,9 @@ async def save_session_as_tree( can_publish, validation_errors = can_publish_tree( tree_structure, tree_name, - request_data.description + request_data.description, + tree_type=original_tree.tree_type, + intake_form=original_tree.intake_form, ) if not can_publish: raise HTTPException( diff --git a/backend/app/api/endpoints/trees.py b/backend/app/api/endpoints/trees.py index 2313ed80..4f943fcb 100644 --- a/backend/app/api/endpoints/trees.py +++ b/backend/app/api/endpoints/trees.py @@ -44,6 +44,7 @@ def build_tree_response(tree: Tree) -> TreeListResponse: id=tree.id, name=tree.name, description=tree.description, + tree_type=tree.tree_type, category=tree.category, category_id=tree.category_id, category_info=category_info, @@ -89,12 +90,14 @@ def build_full_tree_response(tree: Tree, parent_tree: Tree = None) -> TreeRespon id=tree.id, name=tree.name, description=tree.description, + tree_type=tree.tree_type, category=tree.category, category_id=tree.category_id, category_info=category_info, tags=tree.tag_names, fork_info=fork_info, tree_structure=tree.tree_structure, + intake_form=tree.intake_form, author_id=tree.author_id, account_id=tree.account_id, is_active=tree.is_active, @@ -112,6 +115,7 @@ def build_full_tree_response(tree: Tree, parent_tree: Tree = None) -> TreeRespon async def list_trees( db: Annotated[AsyncSession, Depends(get_db)], current_user: Annotated[User, Depends(get_current_active_user)], + tree_type: Optional[str] = Query(None, description="Filter by tree type: troubleshooting or procedural"), category: Optional[str] = Query(None, description="Filter by legacy category string"), category_id: Optional[UUID] = Query(None, description="Filter by category ID"), tags: Optional[str] = Query(None, description="Comma-separated tag slugs to filter by"), @@ -137,6 +141,8 @@ async def list_trees( ) # Apply filters + if tree_type: + query = query.where(Tree.tree_type == tree_type) if category: query = query.where(Tree.category == category) if category_id: @@ -297,10 +303,16 @@ async def create_tree( """ # Validate tree if status is 'published' if tree_data.status == 'published': + # Convert intake_form to dicts for validation + intake_form_dicts = None + if tree_data.intake_form: + intake_form_dicts = [f.model_dump() for f in tree_data.intake_form] can_publish, validation_errors = can_publish_tree( tree_data.tree_structure, tree_data.name, - tree_data.description + tree_data.description, + tree_type=tree_data.tree_type, + intake_form=intake_form_dicts, ) if not can_publish: raise HTTPException( @@ -332,12 +344,19 @@ async def create_tree( detail="You don't have access to this category" ) + # Convert intake_form Pydantic models to dicts for JSONB storage + intake_form_data = None + if tree_data.intake_form: + intake_form_data = [f.model_dump(exclude_none=True) for f in tree_data.intake_form] + new_tree = Tree( name=tree_data.name, description=tree_data.description, category=tree_data.category, category_id=tree_data.category_id, + tree_type=tree_data.tree_type, tree_structure=tree_data.tree_structure, + intake_form=intake_form_data, author_id=None if is_default else current_user.id, # Default trees have no author account_id=None if is_default else current_user.account_id, is_public=True if is_default else tree_data.is_public, # Default trees are always public @@ -463,11 +482,15 @@ async def update_tree( final_tree_structure = update_data.get("tree_structure", tree.tree_structure) final_name = update_data.get("name", tree.name) final_description = update_data.get("description", tree.description) + final_tree_type = update_data.get("tree_type", tree.tree_type) + final_intake_form = update_data.get("intake_form", tree.intake_form) can_publish, validation_errors = can_publish_tree( final_tree_structure, final_name, - final_description + final_description, + tree_type=final_tree_type, + intake_form=final_intake_form, ) if not can_publish: raise HTTPException( @@ -673,7 +696,9 @@ async def fork_tree( description=parent.description, category=parent.category, category_id=parent.category_id, + tree_type=parent.tree_type, tree_structure=parent.tree_structure, + intake_form=parent.intake_form, author_id=current_user.id, account_id=current_user.account_id, is_public=False, @@ -996,7 +1021,9 @@ async def check_tree_can_publish( can_publish, validation_errors = can_publish_tree( tree.tree_structure, tree.name, - tree.description + tree.description, + tree_type=tree.tree_type, + intake_form=tree.intake_form, ) return TreeValidationResponse( diff --git a/backend/app/core/tree_validation.py b/backend/app/core/tree_validation.py index a9127f93..47afed51 100644 --- a/backend/app/core/tree_validation.py +++ b/backend/app/core/tree_validation.py @@ -10,8 +10,10 @@ class TreeValidationError(Exception): super().__init__(f"{field}: {message}") +# --- Troubleshooting Tree Validation --- + def validate_tree_structure(tree_structure: dict[str, Any]) -> tuple[bool, list[dict[str, str]]]: - """Validate tree structure for publishing. + """Validate troubleshooting tree structure for publishing. A valid tree for publishing must have: - A root node with id, type, and appropriate content fields @@ -53,13 +55,7 @@ def validate_tree_structure(tree_structure: dict[str, Any]) -> tuple[bool, list[ def _validate_node(node: dict[str, Any], path: str, errors: list[dict[str, str]]) -> None: - """Validate a single node in the tree structure. - - Args: - node: The node dict to validate - path: Current path in the tree (for error messages) - errors: List to append errors to - """ + """Validate a single node in the tree structure.""" node_type = node.get("type") if node_type == "decision": @@ -99,13 +95,7 @@ def _validate_node(node: dict[str, Any], path: str, errors: list[dict[str, str]] def _validate_children(children: list[dict[str, Any]], path: str, errors: list[dict[str, str]]) -> None: - """Recursively validate child nodes. - - Args: - children: List of child nodes - path: Current path in the tree (for error messages) - errors: List to append errors to - """ + """Recursively validate child nodes.""" for i, child in enumerate(children): child_path = f"{path}[{i}]" @@ -123,17 +113,106 @@ def _validate_children(children: list[dict[str, Any]], path: str, errors: list[d _validate_children(child["children"], f"{child_path}.children", errors) -def can_publish_tree(tree_structure: dict[str, Any], name: str, description: str | None = None) -> tuple[bool, list[dict[str, str]]]: +# --- Procedural Tree Validation --- + +VALID_STEP_TYPES = {"procedure_step", "procedure_end"} +VALID_CONTENT_TYPES = {"action", "informational", "verification", "warning"} + + +def validate_procedural_structure(tree_structure: dict[str, Any]) -> tuple[bool, list[dict[str, str]]]: + """Validate procedural tree structure for publishing. + + Procedural trees store steps as a flat ordered array in tree_structure["steps"]. + + Rules: + - Must have a non-empty "steps" array + - Each step must have: id, type, title + - Only procedure_step and procedure_end types allowed + - Must have exactly one procedure_end (as the last step) + - All other steps must be procedure_step + - No duplicate step IDs + - Steps with content_type must use valid values + + Args: + tree_structure: Dict with a "steps" key containing the ordered step array + + Returns: + Tuple of (is_valid, list of errors) + """ + errors = [] + + if not tree_structure: + errors.append({"field": "tree_structure", "message": "Tree structure cannot be empty"}) + return False, errors + + steps = tree_structure.get("steps") + if not steps or not isinstance(steps, list): + errors.append({"field": "tree_structure.steps", "message": "Procedural tree must have a non-empty steps array"}) + return False, errors + + # Track IDs for uniqueness + seen_ids: set[str] = set() + end_count = 0 + + for i, step in enumerate(steps): + path = f"steps[{i}]" + + # Required fields + step_id = step.get("id") + if not step_id: + errors.append({"field": f"{path}.id", "message": "Step must have an id"}) + elif step_id in seen_ids: + errors.append({"field": f"{path}.id", "message": f"Duplicate step id: {step_id}"}) + else: + seen_ids.add(step_id) + + step_type = step.get("type") + if not step_type: + errors.append({"field": f"{path}.type", "message": "Step must have a type"}) + elif step_type not in VALID_STEP_TYPES: + errors.append({"field": f"{path}.type", "message": f"Invalid step type: {step_type}. Must be one of: {', '.join(VALID_STEP_TYPES)}"}) + elif step_type == "procedure_end": + end_count += 1 + # procedure_end must be last step + if i != len(steps) - 1: + errors.append({"field": f"{path}.type", "message": "procedure_end must be the last step"}) + + if not step.get("title"): + errors.append({"field": f"{path}.title", "message": "Step must have a non-empty title"}) + + # Validate content_type if present + content_type = step.get("content_type") + if content_type and content_type not in VALID_CONTENT_TYPES: + errors.append({"field": f"{path}.content_type", "message": f"Invalid content_type: {content_type}. Must be one of: {', '.join(VALID_CONTENT_TYPES)}"}) + + # Must have exactly one end step + if end_count == 0: + errors.append({"field": "tree_structure.steps", "message": "Procedural tree must have a procedure_end step as the last step"}) + elif end_count > 1: + errors.append({"field": "tree_structure.steps", "message": "Procedural tree must have exactly one procedure_end step"}) + + return len(errors) == 0, errors + + +# --- Dispatch --- + +def can_publish_tree( + tree_structure: dict[str, Any], + name: str, + description: str | None = None, + tree_type: str = "troubleshooting", + intake_form: list[dict[str, Any]] | None = None, +) -> tuple[bool, list[dict[str, str]]]: """Check if a tree can be published. - Validates: - - Tree has a name (non-empty) - - Tree structure is valid + Dispatches to the appropriate validator based on tree_type. Args: tree_structure: The tree structure to validate name: The tree name description: Optional tree description + tree_type: 'troubleshooting' or 'procedural' + intake_form: Optional intake form fields (procedural only) Returns: Tuple of (can_publish, list of errors) @@ -144,8 +223,44 @@ def can_publish_tree(tree_structure: dict[str, Any], name: str, description: str if not name or not name.strip(): errors.append({"field": "name", "message": "Tree must have a name to be published"}) - # Validate tree structure - structure_valid, structure_errors = validate_tree_structure(tree_structure) + # Validate structure based on tree type + if tree_type == "procedural": + structure_valid, structure_errors = validate_procedural_structure(tree_structure) + else: + structure_valid, structure_errors = validate_tree_structure(tree_structure) errors.extend(structure_errors) + # Validate intake form if present (procedural only) + if intake_form and tree_type == "procedural": + form_valid, form_errors = _validate_intake_form(intake_form) + errors.extend(form_errors) + + return len(errors) == 0, errors + + +def _validate_intake_form(intake_form: list[dict[str, Any]]) -> tuple[bool, list[dict[str, str]]]: + """Validate intake form field definitions.""" + errors = [] + variable_names: set[str] = set() + + for i, field in enumerate(intake_form): + path = f"intake_form[{i}]" + + var_name = field.get("variable_name") + if not var_name: + errors.append({"field": f"{path}.variable_name", "message": "Field must have a variable_name"}) + elif var_name in variable_names: + errors.append({"field": f"{path}.variable_name", "message": f"Duplicate variable_name: {var_name}"}) + else: + variable_names.add(var_name) + + if not field.get("label"): + errors.append({"field": f"{path}.label", "message": "Field must have a label"}) + + field_type = field.get("field_type") + if field_type in ("select", "multi_select"): + options = field.get("options") + if not options or len(options) == 0: + errors.append({"field": f"{path}.options", "message": f"{field_type} fields must have at least one option"}) + return len(errors) == 0, errors diff --git a/backend/app/models/tree.py b/backend/app/models/tree.py index 917c0142..f36dd961 100644 --- a/backend/app/models/tree.py +++ b/backend/app/models/tree.py @@ -28,6 +28,10 @@ class Tree(Base): "status IN ('draft', 'published')", name='ck_trees_status' ), + CheckConstraint( + "tree_type IN ('troubleshooting', 'procedural')", + name='ck_trees_tree_type' + ), ) id: Mapped[uuid.UUID] = mapped_column( @@ -47,7 +51,20 @@ class Tree(Base): nullable=True, index=True ) + tree_type: Mapped[str] = mapped_column( + String(20), + nullable=False, + default='troubleshooting', + server_default='troubleshooting', + index=True, + comment="Tree type: troubleshooting (branching decision tree) or procedural (linear step-by-step flow)" + ) tree_structure: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False) + intake_form: Mapped[Optional[list[dict[str, Any]]]] = mapped_column( + JSONB, + nullable=True, + comment="Intake form field definitions for procedural flows (JSONB array)" + ) author_id: Mapped[Optional[uuid.UUID]] = mapped_column( UUID(as_uuid=True), ForeignKey("users.id"), diff --git a/backend/app/schemas/session.py b/backend/app/schemas/session.py index be226993..4f00f618 100644 --- a/backend/app/schemas/session.py +++ b/backend/app/schemas/session.py @@ -41,6 +41,7 @@ class SessionCreate(BaseModel): tree_id: UUID ticket_number: Optional[str] = Field(None, max_length=100) client_name: Optional[str] = Field(None, max_length=255) + session_variables: Optional[dict[str, str]] = Field(None, description="Intake form values for procedural flows") class SessionUpdate(BaseModel): diff --git a/backend/app/schemas/tree.py b/backend/app/schemas/tree.py index 0972f9f7..11e6b746 100644 --- a/backend/app/schemas/tree.py +++ b/backend/app/schemas/tree.py @@ -1,7 +1,69 @@ from datetime import datetime from typing import Optional, Any, Literal from uuid import UUID -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator, model_validator +import re + + +# --- Tree Type --- + +TreeType = Literal['troubleshooting', 'procedural'] + +# --- Intake Form Schemas --- + +FIELD_TYPES = Literal[ + 'text', 'textarea', 'number', 'ip_address', 'email', + 'select', 'multi_select', 'checkbox', 'password' +] + +VARIABLE_NAME_PATTERN = re.compile(r'^[a-z][a-z0-9_]*$') + + +class IntakeFieldValidation(BaseModel): + """Validation config for an intake form field.""" + min_length: Optional[int] = None + max_length: Optional[int] = None + pattern: Optional[str] = None + pattern_message: Optional[str] = None + format: Optional[Literal['ipv4', 'email']] = None + min_value: Optional[float] = None + max_value: Optional[float] = None + min_selections: Optional[int] = None + max_selections: Optional[int] = None + + +class IntakeFormField(BaseModel): + """Schema for a single intake form field definition.""" + variable_name: str = Field(..., min_length=1, max_length=50) + label: str = Field(..., min_length=1, max_length=100) + field_type: FIELD_TYPES + required: bool = True + options: Optional[list[str]] = None + placeholder: Optional[str] = Field(None, max_length=200) + help_text: Optional[str] = Field(None, max_length=500) + default_value: Optional[str] = None + group_name: Optional[str] = Field(None, max_length=100) + display_order: int = Field(..., ge=1) + validation: Optional[IntakeFieldValidation] = None + + @field_validator('variable_name') + @classmethod + def validate_variable_name(cls, v: str) -> str: + if not VARIABLE_NAME_PATTERN.match(v): + raise ValueError( + 'Variable name must start with a lowercase letter and contain only ' + 'lowercase letters, numbers, and underscores' + ) + return v + + @model_validator(mode='after') + def validate_options_for_select(self): + if self.field_type in ('select', 'multi_select'): + if not self.options or len(self.options) == 0: + raise ValueError( + f'{self.field_type} fields must have at least one option' + ) + return self class CategoryInfo(BaseModel): @@ -22,25 +84,45 @@ class TreeBase(BaseModel): class TreeCreate(TreeBase): + tree_type: TreeType = Field('troubleshooting', description="Tree type: troubleshooting or procedural") tree_structure: dict[str, Any] = Field(..., description="The decision tree structure in JSON format") + intake_form: Optional[list[IntakeFormField]] = Field(None, description="Intake form field definitions (procedural flows only)") is_public: bool = Field(False, description="Make tree visible to all users") is_default: bool = Field(False, description="Mark as a default/system tree (admin only)") status: Literal['draft', 'published'] = Field('published', description="Status: draft or published") category_id: Optional[UUID] = Field(None, description="Category ID from tree_categories table") tags: Optional[list[str]] = Field(None, max_length=10, description="List of tag names to assign") + @model_validator(mode='after') + def validate_intake_form_unique_variables(self): + if self.intake_form: + names = [f.variable_name for f in self.intake_form] + if len(names) != len(set(names)): + raise ValueError('Intake form variable names must be unique') + return self + class TreeUpdate(BaseModel): name: Optional[str] = Field(None, min_length=1, max_length=255) description: Optional[str] = None category: Optional[str] = Field(None, max_length=100) category_id: Optional[UUID] = None + tree_type: Optional[TreeType] = None tree_structure: Optional[dict[str, Any]] = None + intake_form: Optional[list[IntakeFormField]] = None is_public: Optional[bool] = None is_active: Optional[bool] = None status: Optional[Literal['draft', 'published']] = None tags: Optional[list[str]] = Field(None, max_length=10, description="List of tag names to assign (replaces existing)") + @model_validator(mode='after') + def validate_intake_form_unique_variables(self): + if self.intake_form: + names = [f.variable_name for f in self.intake_form] + if len(names) != len(set(names)): + raise ValueError('Intake form variable names must be unique') + return self + class ForkCreate(BaseModel): fork_reason: Optional[str] = Field(None, max_length=255, description="Brief reason for forking") @@ -62,7 +144,9 @@ class ForkInfo(BaseModel): class TreeResponse(TreeBase): id: UUID + tree_type: str = 'troubleshooting' tree_structure: dict[str, Any] + intake_form: Optional[list[dict[str, Any]]] = None author_id: Optional[UUID] = None account_id: Optional[UUID] = None category_id: Optional[UUID] = None @@ -86,6 +170,7 @@ class TreeListResponse(BaseModel): id: UUID name: str description: Optional[str] = None + tree_type: str = 'troubleshooting' category: Optional[str] = None category_id: Optional[UUID] = None category_info: Optional[CategoryInfo] = None diff --git a/backend/app/services/export_service.py b/backend/app/services/export_service.py index d38a92c4..8654eb89 100644 --- a/backend/app/services/export_service.py +++ b/backend/app/services/export_service.py @@ -171,6 +171,8 @@ def _escape_markdown_table(value: str) -> str: def generate_markdown_export(session: Session, options: SessionExport) -> str: """Generate markdown export.""" + if _is_procedural_session(session): + return _generate_procedural_markdown(session, options) lines = [] outcome_label = _get_outcome_label(session) @@ -284,6 +286,8 @@ def generate_markdown_export(session: Session, options: SessionExport) -> str: def generate_text_export(session: Session, options: SessionExport) -> str: """Generate plain text export.""" + if _is_procedural_session(session): + return _generate_procedural_text(session, options) lines = [] outcome_label = _get_outcome_label(session) @@ -378,6 +382,8 @@ def generate_text_export(session: Session, options: SessionExport) -> str: def generate_html_export(session: Session, options: SessionExport) -> str: """Generate HTML export.""" + if _is_procedural_session(session): + return _generate_procedural_html(session, options) tree_name = html.escape(session.tree_snapshot.get("name", "Troubleshooting Session")) outcome_label = _get_outcome_label(session) @@ -484,6 +490,8 @@ def generate_html_export(session: Session, options: SessionExport) -> str: def generate_psa_export(session: Session, options: SessionExport) -> str: """Generate PSA/ticket note export optimized for ConnectWise and similar PSA tools.""" + if _is_procedural_session(session): + return _generate_procedural_psa(session, options) lines = [] outcome_label = _get_outcome_label(session) @@ -588,3 +596,311 @@ def generate_psa_export(session: Session, options: SessionExport) -> str: lines.append(scratchpad.strip() if scratchpad.strip() else "None") return "\n".join(lines) + + +def _is_procedural_session(session: Session) -> bool: + """Check if session is for a procedural flow.""" + return session.tree_snapshot.get("tree_type") == "procedural" + + +def _get_session_variables(session: Session) -> dict[str, str]: + """Get session variables (intake form values) from session.""" + variables = getattr(session, "session_variables", None) + if isinstance(variables, dict): + return variables + return {} + + +def _generate_procedural_markdown(session: Session, options: SessionExport) -> str: + """Generate markdown export for procedural sessions.""" + lines = [] + outcome_label = _get_outcome_label(session) + tree_name = session.tree_snapshot.get("name", "Procedure") + + if options.include_tree_info: + lines.append(f"# {tree_name}") + lines.append("") + if session.ticket_number: + lines.append(f"**Ticket:** {session.ticket_number}") + if session.client_name: + lines.append(f"**Client:** {session.client_name}") + if options.include_timestamps: + lines.append(f"**Started:** {session.started_at.strftime('%Y-%m-%d %H:%M')}") + if session.completed_at: + lines.append(f"**Completed:** {session.completed_at.strftime('%Y-%m-%d %H:%M')}") + lines.append(f"**Duration:** {_format_duration(session.started_at, session.completed_at)}") + if outcome_label: + lines.append(f"**Outcome:** {outcome_label}") + lines.append("") + lines.append("---") + lines.append("") + + # Project Parameters + variables = _get_session_variables(session) + if variables: + lines.append("## Project Parameters") + lines.append("") + lines.append("| Parameter | Value |") + lines.append("|-----------|-------|") + for key, value in variables.items(): + lines.append(f"| `{_escape_markdown_table(key)}` | {_escape_markdown_table(value)} |") + lines.append("") + lines.append("---") + lines.append("") + + # Steps + lines.append("## Procedure Steps") + lines.append("") + + decisions = session.decisions or [] + if options.max_step_index is not None: + decisions = decisions[:options.max_step_index] + + for i, decision in enumerate(decisions, 1): + title = decision.get("question") or decision.get("action_performed", "Step") + notes = decision.get("notes", "") + verification = _get_command_output(decision) + completed = decision.get("answer") == "completed" + checkbox = "[x]" if completed else "[ ]" + lines.append(f"- {checkbox} **Step {i}: {title}**") + if notes: + lines.append(f" - Notes: {notes}") + if verification: + lines.append(f" - Verification: {verification}") + duration_seconds = _get_step_duration_seconds(decision) + if duration_seconds is not None: + lines.append(f" - Duration: {_format_step_duration(duration_seconds)}") + + lines.append("") + + # Resolution + _raw_notes = getattr(session, 'outcome_notes', None) + outcome_notes = _raw_notes if isinstance(_raw_notes, str) else '' + if outcome_notes.strip() and options.include_outcome_notes: + lines.append("---") + lines.append("") + lines.append("## Notes") + lines.append("") + lines.append(outcome_notes.strip()) + lines.append("") + + return "\n".join(lines) + + +def _generate_procedural_text(session: Session, options: SessionExport) -> str: + """Generate plain text export for procedural sessions.""" + lines = [] + outcome_label = _get_outcome_label(session) + tree_name = session.tree_snapshot.get("name", "Procedure") + + if options.include_tree_info: + lines.append(tree_name) + lines.append("=" * len(tree_name)) + if session.ticket_number: + lines.append(f"Ticket: {session.ticket_number}") + if session.client_name: + lines.append(f"Client: {session.client_name}") + if options.include_timestamps: + lines.append(f"Started: {session.started_at.strftime('%Y-%m-%d %H:%M')}") + if session.completed_at: + lines.append(f"Completed: {session.completed_at.strftime('%Y-%m-%d %H:%M')}") + lines.append(f"Duration: {_format_duration(session.started_at, session.completed_at)}") + if outcome_label: + lines.append(f"Outcome: {outcome_label}") + lines.append("") + + # Project Parameters + variables = _get_session_variables(session) + if variables: + lines.append("PROJECT PARAMETERS") + lines.append("-" * 20) + for key, value in variables.items(): + lines.append(f" {key}: {value}") + lines.append("") + + lines.append("PROCEDURE STEPS") + lines.append("-" * 20) + + decisions = session.decisions or [] + if options.max_step_index is not None: + decisions = decisions[:options.max_step_index] + + for i, decision in enumerate(decisions, 1): + title = decision.get("question") or decision.get("action_performed", "Step") + notes = decision.get("notes", "") + verification = _get_command_output(decision) + completed = decision.get("answer") == "completed" + marker = "[DONE]" if completed else "[ ]" + lines.append(f"\n{marker} {i}. {title}") + if notes: + lines.append(f" Notes: {notes}") + if verification: + lines.append(f" Verification: {verification}") + duration_seconds = _get_step_duration_seconds(decision) + if duration_seconds is not None: + lines.append(f" Duration: {_format_step_duration(duration_seconds)}") + + lines.append("") + + _raw_notes = getattr(session, 'outcome_notes', None) + outcome_notes = _raw_notes if isinstance(_raw_notes, str) else '' + if outcome_notes.strip() and options.include_outcome_notes: + lines.append("NOTES") + lines.append("-" * 20) + lines.append(outcome_notes.strip()) + + return "\n".join(lines) + + +def _generate_procedural_html(session: Session, options: SessionExport) -> str: + """Generate HTML export for procedural sessions.""" + tree_name = html.escape(session.tree_snapshot.get("name", "Procedure")) + outcome_label = _get_outcome_label(session) + + html_parts = ['', '', '', + '', + f'{tree_name}', + '', + '', ''] + + if options.include_tree_info: + html_parts.append(f'

{tree_name}

') + html_parts.append('
') + if session.ticket_number: + html_parts.append(f'

Ticket: {html.escape(session.ticket_number)}

') + if session.client_name: + html_parts.append(f'

Client: {html.escape(session.client_name)}

') + if options.include_timestamps: + html_parts.append(f'

Started: {session.started_at.strftime("%Y-%m-%d %H:%M")}

') + if session.completed_at: + html_parts.append(f'

Completed: {session.completed_at.strftime("%Y-%m-%d %H:%M")}

') + html_parts.append(f'

Duration: {_format_duration(session.started_at, session.completed_at)}

') + if outcome_label: + html_parts.append(f'

Outcome: {html.escape(outcome_label)}

') + html_parts.append('
') + + # Project Parameters + variables = _get_session_variables(session) + if variables: + html_parts.append('
') + html_parts.append('

Project Parameters

') + html_parts.append('') + for key, value in variables.items(): + html_parts.append(f'') + html_parts.append('
{html.escape(key)}{html.escape(value)}
') + html_parts.append('
') + + html_parts.append('

Procedure Steps

') + + decisions = session.decisions or [] + if options.max_step_index is not None: + decisions = decisions[:options.max_step_index] + + for i, decision in enumerate(decisions, 1): + title = html.escape(decision.get("question") or decision.get("action_performed", "Step")) + notes = html.escape(decision.get("notes", "")) + verification = _get_command_output(decision) + completed = decision.get("answer") == "completed" + css_class = "step done" if completed else "step" + marker = "✅" if completed else "⬜" + + html_parts.append(f'
') + html_parts.append(f'

{marker} Step {i}: {title}

') + if notes: + html_parts.append(f'

Notes: {notes}

') + if verification: + html_parts.append(f'

Verification: {html.escape(verification)}

') + duration_seconds = _get_step_duration_seconds(decision) + if duration_seconds is not None: + html_parts.append(f'

Duration: {_format_step_duration(duration_seconds)}

') + html_parts.append('
') + + # Resolution + _raw_notes = getattr(session, 'outcome_notes', None) + outcome_notes = _raw_notes if isinstance(_raw_notes, str) else '' + if outcome_notes.strip() and options.include_outcome_notes: + html_parts.append('

Notes

') + html_parts.append(f'
{html.escape(outcome_notes.strip())}
') + + html_parts.extend(['', '']) + return "\n".join(html_parts) + + +def _generate_procedural_psa(session: Session, options: SessionExport) -> str: + """Generate PSA/ticket export for procedural sessions.""" + lines = [] + outcome_label = _get_outcome_label(session) + tree_name = session.tree_snapshot.get("name", "Procedure") + ticket_number = session.ticket_number or "N/A" + client_name = session.client_name or "N/A" + date_str = session.started_at.strftime("%Y-%m-%d %H:%M") + + lines.append("=== PROCEDURE NOTES ===") + lines.append(f"Ticket: {ticket_number} | Client: {client_name}") + lines.append(f"Procedure: {tree_name} | Date: {date_str}") + lines.append(f"Duration: {_format_duration(session.started_at, session.completed_at)}") + if outcome_label: + lines.append(f"Outcome: {outcome_label}") + lines.append("") + + # Project Parameters + variables = _get_session_variables(session) + if variables: + lines.append("--- PROJECT PARAMETERS ---") + for key, value in variables.items(): + lines.append(f" {key}: {value}") + lines.append("") + + # Steps + lines.append("--- STEPS COMPLETED ---") + decisions = session.decisions or [] + if options.max_step_index is not None: + decisions = decisions[:options.max_step_index] + + if decisions: + for i, decision in enumerate(decisions, 1): + title = decision.get("question") or decision.get("action_performed", "Step") + notes = decision.get("notes", "") + completed = decision.get("answer") == "completed" + marker = "[DONE]" if completed else "[ ]" + duration_seconds = _get_step_duration_seconds(decision) + line = f"{marker} {i}. {title}" + if duration_seconds is not None: + line += f" ({_format_step_duration(duration_seconds)})" + lines.append(line) + if notes: + lines.append(f" Notes: {notes}") + else: + lines.append("No steps recorded.") + lines.append("") + + # Resolution + if session.completed_at: + lines.append("--- RESOLUTION ---") + _raw_notes = getattr(session, 'outcome_notes', None) + outcome_notes = _raw_notes if isinstance(_raw_notes, str) else '' + if outcome_notes.strip() and options.include_outcome_notes: + lines.append(outcome_notes.strip()) + else: + lines.append("Procedure completed successfully.") + if outcome_label: + lines.append(f"Outcome: {outcome_label}") + lines.append("") + + # Time spent + lines.append("--- TIME SPENT ---") + lines.append(f"Duration: {_format_duration(session.started_at, session.completed_at)}") + + return "\n".join(lines) diff --git a/backend/scripts/seed_procedural_flows.py b/backend/scripts/seed_procedural_flows.py new file mode 100644 index 00000000..fe1dec24 --- /dev/null +++ b/backend/scripts/seed_procedural_flows.py @@ -0,0 +1,1002 @@ +#!/usr/bin/env python3 +""" +Procedural Flow Seed Script for ResolutionFlow. + +Creates sample procedural flows (step-by-step project templates) with intake forms +for common MSP project work. + +Run from the backend directory with: python -m scripts.seed_procedural_flows + +Requirements: +- Backend server must be running (uvicorn app.main:app) +""" + +import asyncio +import argparse +import httpx +from typing import Any + + +# API Configuration +API_BASE_URL = "http://localhost:8000/api/v1" + +ADMIN_EMAIL = None +ADMIN_PASSWORD = None + + +# ============================================================================= +# PROCEDURAL FLOW DEFINITIONS +# ============================================================================= + +def get_domain_controller_flow() -> dict[str, Any]: + """Domain Controller & Active Directory Setup — full build procedure.""" + return { + "name": "Domain Controller & Active Directory Setup", + "description": "Complete procedure for building a new domain controller, installing Active Directory Domain Services, configuring DNS, and verifying replication. Includes intake form for server and domain details.", + "tree_type": "procedural", + "category": "Projects - Infrastructure", + "tags": ["active-directory", "dns", "domain-controller", "server-build"], + "intake_form": [ + { + "variable_name": "server_name", + "label": "Server Name", + "field_type": "text", + "required": True, + "placeholder": "e.g. DC01, YOURCOMPANY-DC02", + "help_text": "NetBIOS name of the new domain controller (max 15 characters)", + "group_name": "Server Details", + "display_order": 1, + }, + { + "variable_name": "server_ip", + "label": "Server IP Address", + "field_type": "ip_address", + "required": True, + "placeholder": "e.g. 10.0.1.10", + "help_text": "Static IP address for this server", + "group_name": "Server Details", + "display_order": 2, + }, + { + "variable_name": "subnet_mask", + "label": "Subnet Mask", + "field_type": "text", + "required": True, + "placeholder": "e.g. 255.255.255.0", + "default_value": "255.255.255.0", + "group_name": "Server Details", + "display_order": 3, + }, + { + "variable_name": "default_gateway", + "label": "Default Gateway", + "field_type": "ip_address", + "required": True, + "placeholder": "e.g. 10.0.1.1", + "group_name": "Server Details", + "display_order": 4, + }, + { + "variable_name": "domain_name", + "label": "Domain Name (FQDN)", + "field_type": "text", + "required": True, + "placeholder": "e.g. corp.contoso.com", + "help_text": "Fully qualified domain name", + "group_name": "Domain Configuration", + "display_order": 5, + }, + { + "variable_name": "netbios_name", + "label": "NetBIOS Domain Name", + "field_type": "text", + "required": True, + "placeholder": "e.g. CONTOSO", + "help_text": "Short domain name (auto-derived from FQDN if unsure)", + "group_name": "Domain Configuration", + "display_order": 6, + }, + { + "variable_name": "is_first_dc", + "label": "Is this the first DC in the domain?", + "field_type": "select", + "required": True, + "options": ["Yes — new forest/domain", "No — additional DC in existing domain"], + "help_text": "Determines whether to create a new forest or join an existing one", + "group_name": "Domain Configuration", + "display_order": 7, + }, + { + "variable_name": "existing_dc_ip", + "label": "Existing DC IP (if adding to existing domain)", + "field_type": "ip_address", + "required": False, + "placeholder": "e.g. 10.0.1.5", + "help_text": "IP of an existing domain controller for replication (leave blank for first DC)", + "group_name": "Domain Configuration", + "display_order": 8, + }, + { + "variable_name": "dsrm_password", + "label": "DSRM Password", + "field_type": "password", + "required": True, + "help_text": "Directory Services Restore Mode password — store securely in password vault", + "group_name": "Security", + "display_order": 9, + }, + { + "variable_name": "server_roles", + "label": "Additional Server Roles", + "field_type": "multi_select", + "required": False, + "options": ["DHCP Server", "Certificate Services (CA)", "DFS Namespaces", "Windows Server Backup", "WSUS"], + "help_text": "Optional roles to install alongside AD DS", + "group_name": "Additional Configuration", + "display_order": 10, + }, + { + "variable_name": "client_name", + "label": "Client / Company Name", + "field_type": "text", + "required": True, + "placeholder": "e.g. Contoso Ltd", + "group_name": "Project Info", + "display_order": 11, + }, + { + "variable_name": "ticket_number", + "label": "Ticket / Project Number", + "field_type": "text", + "required": False, + "placeholder": "e.g. PRJ-2024-0042", + "group_name": "Project Info", + "display_order": 12, + }, + ], + "tree_structure": { + "steps": [ + { + "id": "step_1", + "type": "procedure_step", + "title": "Verify Prerequisites", + "content_type": "verification", + "description": "Before beginning, verify the following prerequisites are met for **[VAR:client_name]** project **[VAR:ticket_number]**:\n\n- Windows Server 2022 (or 2019) is installed and activated on **[VAR:server_name]**\n- Server has network connectivity\n- You have local administrator credentials\n- The IP address **[VAR:server_ip]** is not in use (ping test)\n- DNS is planned — if this is a new domain, the DC will be its own DNS server", + "verification": { + "type": "checkbox", + "prompt": "All prerequisites verified?" + }, + }, + { + "id": "step_2", + "type": "procedure_step", + "title": "Configure Static IP Address", + "content_type": "action", + "description": "Set the static IP address on the primary network adapter of **[VAR:server_name]**.", + "commands": [ + { + "language": "powershell", + "code": "# Get the primary network adapter\n$adapter = Get-NetAdapter | Where-Object { $_.Status -eq 'Up' } | Select-Object -First 1\n\n# Remove existing IP configuration\nRemove-NetIPAddress -InterfaceIndex $adapter.ifIndex -Confirm:$false -ErrorAction SilentlyContinue\nRemove-NetRoute -InterfaceIndex $adapter.ifIndex -Confirm:$false -ErrorAction SilentlyContinue\n\n# Set static IP\nNew-NetIPAddress -InterfaceIndex $adapter.ifIndex `\n -IPAddress \"[VAR:server_ip]\" `\n -PrefixLength 24 `\n -DefaultGateway \"[VAR:default_gateway]\"\n\n# Set DNS — point to self (will be DNS server after AD install)\nSet-DnsClientServerAddress -InterfaceIndex $adapter.ifIndex `\n -ServerAddresses \"[VAR:server_ip]\",\"[VAR:default_gateway]\"", + "label": "Set Static IP via PowerShell" + } + ], + "expected_outcome": "Server should have IP **[VAR:server_ip]** with gateway **[VAR:default_gateway]**. Verify with `ipconfig /all`.", + }, + { + "id": "step_3", + "type": "procedure_step", + "title": "Rename Server", + "content_type": "action", + "description": "Rename the server to **[VAR:server_name]** and reboot.", + "commands": [ + { + "language": "powershell", + "code": "# Rename the computer\nRename-Computer -NewName \"[VAR:server_name]\" -Force -Restart", + "label": "Rename and restart" + } + ], + "expected_outcome": "Server reboots with hostname **[VAR:server_name]**. Wait for reboot to complete before proceeding.", + "verification": { + "type": "checkbox", + "prompt": "Server has rebooted and hostname is correct?" + }, + }, + { + "id": "step_4", + "type": "procedure_step", + "title": "Install AD DS Role", + "content_type": "action", + "description": "Install the Active Directory Domain Services role and management tools on **[VAR:server_name]**.", + "commands": [ + { + "language": "powershell", + "code": "# Install AD DS role with management tools\nInstall-WindowsFeature AD-Domain-Services -IncludeManagementTools -Verbose", + "label": "Install AD DS" + } + ], + "expected_outcome": "Feature installation completes successfully. No reboot required at this stage.", + }, + { + "id": "step_5", + "type": "procedure_step", + "title": "Promote to Domain Controller", + "content_type": "action", + "description": "Promote **[VAR:server_name]** to a domain controller for **[VAR:domain_name]**.\n\n**Important:** The DSRM password should be stored securely in your password vault immediately after this step.", + "commands": [ + { + "language": "powershell", + "code": "# For a NEW forest/domain:\nInstall-ADDSForest `\n -DomainName \"[VAR:domain_name]\" `\n -DomainNetBIOSName \"[VAR:netbios_name]\" `\n -ForestMode \"WinThreshold\" `\n -DomainMode \"WinThreshold\" `\n -InstallDns:$true `\n -DatabasePath \"C:\\Windows\\NTDS\" `\n -LogPath \"C:\\Windows\\NTDS\" `\n -SysvolPath \"C:\\Windows\\SYSVOL\" `\n -SafeModeAdministratorPassword (Read-Host -AsSecureString \"DSRM Password\") `\n -Force:$true", + "label": "Promote — New Forest" + }, + { + "language": "powershell", + "code": "# For ADDITIONAL DC in existing domain:\nInstall-ADDSDomainController `\n -DomainName \"[VAR:domain_name]\" `\n -InstallDns:$true `\n -DatabasePath \"C:\\Windows\\NTDS\" `\n -LogPath \"C:\\Windows\\NTDS\" `\n -SysvolPath \"C:\\Windows\\SYSVOL\" `\n -SafeModeAdministratorPassword (Read-Host -AsSecureString \"DSRM Password\") `\n -Force:$true", + "label": "Promote — Additional DC" + } + ], + "expected_outcome": "Server reboots automatically after promotion. This may take 5-10 minutes. After reboot, log in with **[VAR:netbios_name]\\Administrator**.", + }, + { + "id": "step_6", + "type": "procedure_step", + "title": "Verify AD DS Installation", + "content_type": "verification", + "description": "After reboot, verify Active Directory and DNS are functioning correctly on **[VAR:server_name]**.", + "commands": [ + { + "language": "powershell", + "code": "# Verify AD DS\nGet-ADDomainController -Filter *\nGet-ADDomain\nGet-ADForest\n\n# Verify DNS\nGet-DnsServerZone\nResolve-DnsName [VAR:domain_name]\n\n# Check SYSVOL and NETLOGON shares\nGet-SmbShare | Where-Object { $_.Name -in 'SYSVOL','NETLOGON' }\n\n# Check DCDIAG\ndcdiag /s:[VAR:server_name]", + "label": "AD & DNS verification" + } + ], + "expected_outcome": "All DCDIAG tests should pass. SYSVOL and NETLOGON shares should be present. DNS zone for **[VAR:domain_name]** should exist.", + "verification": { + "type": "checkbox", + "prompt": "DCDIAG passed, SYSVOL/NETLOGON shares present, DNS zone exists?" + }, + }, + { + "id": "step_7", + "type": "procedure_step", + "title": "Configure DNS Forwarders", + "content_type": "action", + "description": "Configure DNS forwarders so **[VAR:server_name]** can resolve external domains.", + "commands": [ + { + "language": "powershell", + "code": "# Set DNS forwarders (Cloudflare + Google as fallback)\nSet-DnsServerForwarder -IPAddress \"1.1.1.1\",\"8.8.8.8\" -PassThru\n\n# Verify\nGet-DnsServerForwarder\n\n# Test external resolution\nResolve-DnsName google.com", + "label": "Configure forwarders" + } + ], + "expected_outcome": "External DNS resolution works. `Resolve-DnsName google.com` should return an IP address.", + }, + { + "id": "step_8", + "type": "procedure_step", + "title": "Create OU Structure", + "content_type": "action", + "description": "Create the organizational unit structure for **[VAR:client_name]**.", + "commands": [ + { + "language": "powershell", + "code": "$domain = (Get-ADDomain).DistinguishedName\n\n# Create top-level OUs\n$ous = @(\n \"OU=Company Users,$domain\",\n \"OU=Company Computers,$domain\",\n \"OU=Company Groups,$domain\",\n \"OU=Company Servers,$domain\",\n \"OU=Service Accounts,$domain\",\n \"OU=Disabled Accounts,$domain\"\n)\n\nforeach ($ou in $ous) {\n $name = ($ou -split ',')[0] -replace 'OU=',''\n try {\n New-ADOrganizationalUnit -Name $name -Path $domain -ProtectedFromAccidentalDeletion $true\n Write-Host \"Created: $name\" -ForegroundColor Green\n } catch {\n Write-Host \"Already exists: $name\" -ForegroundColor Yellow\n }\n}", + "label": "Create OUs" + } + ], + "expected_outcome": "OU structure visible in Active Directory Users and Computers.", + }, + { + "id": "step_9", + "type": "procedure_step", + "title": "Configure Group Policy Defaults", + "content_type": "action", + "description": "Configure essential Group Policy settings for the new domain.", + "commands": [ + { + "language": "powershell", + "code": "# Set password policy\nSet-ADDefaultDomainPasswordPolicy -Identity \"[VAR:domain_name]\" `\n -LockoutThreshold 5 `\n -LockoutDuration \"00:30:00\" `\n -LockoutObservationWindow \"00:30:00\" `\n -MaxPasswordAge \"90.00:00:00\" `\n -MinPasswordAge \"1.00:00:00\" `\n -MinPasswordLength 12 `\n -PasswordHistoryCount 10 `\n -ComplexityEnabled $true\n\n# Verify\nGet-ADDefaultDomainPasswordPolicy", + "label": "Set password policy" + } + ], + "expected_outcome": "Password policy configured: 12 char minimum, complexity enabled, 5 lockout threshold, 90-day max age.", + }, + { + "id": "step_10", + "type": "procedure_step", + "title": "Document and Update Password Vault", + "content_type": "informational", + "description": "Document the following in the client's IT documentation and password vault:\n\n| Item | Value |\n|------|-------|\n| Server Name | **[VAR:server_name]** |\n| IP Address | **[VAR:server_ip]** |\n| Domain | **[VAR:domain_name]** |\n| NetBIOS | **[VAR:netbios_name]** |\n| DSRM Password | *(stored in vault)* |\n| DNS Forwarders | 1.1.1.1, 8.8.8.8 |\n\nUpdate the client's network diagram and runbook with the new DC information.", + "verification": { + "type": "checkbox", + "prompt": "Documentation and password vault updated?" + }, + }, + { + "id": "step_end", + "type": "procedure_end", + "title": "Domain Controller Setup Complete", + "description": "**[VAR:server_name]** has been configured as a domain controller for **[VAR:domain_name]** at **[VAR:client_name]**.\n\n**Next steps to consider:**\n- Configure DHCP if selected as an additional role\n- Set up Azure AD Connect for hybrid identity\n- Configure backup solution for System State\n- Schedule a replication health check in 24 hours", + }, + ] + }, + } + + +def get_m365_user_onboarding_flow() -> dict[str, Any]: + """Microsoft 365 User Onboarding — new hire setup procedure.""" + return { + "name": "Microsoft 365 User Onboarding", + "description": "Step-by-step procedure for creating a new Microsoft 365 user account, assigning licenses, configuring email, adding to security groups, and setting up MFA. Includes intake form for new user details.", + "tree_type": "procedural", + "category": "Projects - Microsoft 365", + "tags": ["m365", "onboarding", "user-setup", "exchange-online"], + "intake_form": [ + { + "variable_name": "first_name", + "label": "First Name", + "field_type": "text", + "required": True, + "placeholder": "e.g. Jane", + "group_name": "User Information", + "display_order": 1, + }, + { + "variable_name": "last_name", + "label": "Last Name", + "field_type": "text", + "required": True, + "placeholder": "e.g. Smith", + "group_name": "User Information", + "display_order": 2, + }, + { + "variable_name": "display_name", + "label": "Display Name", + "field_type": "text", + "required": True, + "placeholder": "e.g. Jane Smith", + "group_name": "User Information", + "display_order": 3, + }, + { + "variable_name": "job_title", + "label": "Job Title", + "field_type": "text", + "required": True, + "placeholder": "e.g. Marketing Manager", + "group_name": "User Information", + "display_order": 4, + }, + { + "variable_name": "department", + "label": "Department", + "field_type": "text", + "required": True, + "placeholder": "e.g. Marketing", + "group_name": "User Information", + "display_order": 5, + }, + { + "variable_name": "manager_email", + "label": "Manager's Email", + "field_type": "email", + "required": False, + "placeholder": "e.g. john.doe@company.com", + "group_name": "User Information", + "display_order": 6, + }, + { + "variable_name": "email_address", + "label": "Email Address", + "field_type": "email", + "required": True, + "placeholder": "e.g. jane.smith@company.com", + "group_name": "Account Setup", + "display_order": 7, + }, + { + "variable_name": "license_type", + "label": "License Type", + "field_type": "select", + "required": True, + "options": [ + "Microsoft 365 Business Basic", + "Microsoft 365 Business Standard", + "Microsoft 365 Business Premium", + "Microsoft 365 E3", + "Microsoft 365 E5", + "Exchange Online Plan 1", + "Exchange Online Plan 2" + ], + "help_text": "Select the license to assign to this user", + "group_name": "Account Setup", + "display_order": 8, + }, + { + "variable_name": "security_groups", + "label": "Security Groups", + "field_type": "textarea", + "required": False, + "placeholder": "One group per line, e.g.:\nMarketing Team\nAll Staff\nVPN Users", + "help_text": "List the security/distribution groups this user should be added to", + "group_name": "Access & Groups", + "display_order": 9, + }, + { + "variable_name": "shared_mailboxes", + "label": "Shared Mailboxes (Full Access)", + "field_type": "textarea", + "required": False, + "placeholder": "One mailbox per line, e.g.:\ninfo@company.com\nsales@company.com", + "help_text": "Shared mailboxes this user needs access to", + "group_name": "Access & Groups", + "display_order": 10, + }, + { + "variable_name": "start_date", + "label": "Start Date", + "field_type": "text", + "required": True, + "placeholder": "e.g. 2025-03-15", + "help_text": "When the user needs access (YYYY-MM-DD)", + "group_name": "Project Info", + "display_order": 11, + }, + { + "variable_name": "client_name", + "label": "Client / Company Name", + "field_type": "text", + "required": True, + "placeholder": "e.g. Contoso Ltd", + "group_name": "Project Info", + "display_order": 12, + }, + { + "variable_name": "ticket_number", + "label": "Ticket Number", + "field_type": "text", + "required": False, + "placeholder": "e.g. TKT-2024-1234", + "group_name": "Project Info", + "display_order": 13, + }, + ], + "tree_structure": { + "steps": [ + { + "id": "step_1", + "type": "procedure_step", + "title": "Verify Prerequisites & Licensing", + "content_type": "verification", + "description": "Before creating the account for **[VAR:display_name]** at **[VAR:client_name]**:\n\n- Confirm **[VAR:license_type]** license is available in the tenant\n- Verify the domain for **[VAR:email_address]** is configured in M365\n- Confirm the email address is not already in use\n- Verify start date: **[VAR:start_date]**\n- Ticket reference: **[VAR:ticket_number]**", + "commands": [ + { + "language": "powershell", + "code": "# Connect to Microsoft Graph (if not already connected)\nConnect-MgGraph -Scopes \"User.ReadWrite.All\",\"Group.ReadWrite.All\",\"Directory.ReadWrite.All\"\n\n# Check available licenses\nGet-MgSubscribedSku | Select-Object SkuPartNumber, ConsumedUnits, @{N='Available';E={$_.PrepaidUnits.Enabled - $_.ConsumedUnits}} | Format-Table\n\n# Check if email is already taken\nGet-MgUser -Filter \"mail eq '[VAR:email_address]'\" -ErrorAction SilentlyContinue", + "label": "Check licenses & email availability" + } + ], + "verification": { + "type": "checkbox", + "prompt": "License available and email address is free?" + }, + }, + { + "id": "step_2", + "type": "procedure_step", + "title": "Create User Account", + "content_type": "action", + "description": "Create the Microsoft 365 user account for **[VAR:display_name]**.", + "commands": [ + { + "language": "powershell", + "code": "# Generate a temporary password\n$tempPassword = -join ((65..90) + (97..122) + (48..57) + (33,35,36,37) | Get-Random -Count 16 | ForEach-Object { [char]$_ })\n\n# Create the user\n$params = @{\n AccountEnabled = $true\n DisplayName = \"[VAR:display_name]\"\n GivenName = \"[VAR:first_name]\"\n Surname = \"[VAR:last_name]\"\n MailNickname = \"[VAR:first_name].[VAR:last_name]\".ToLower()\n UserPrincipalName = \"[VAR:email_address]\"\n JobTitle = \"[VAR:job_title]\"\n Department = \"[VAR:department]\"\n PasswordProfile = @{\n ForceChangePasswordNextSignIn = $true\n Password = $tempPassword\n }\n UsageLocation = \"US\"\n}\n\n$newUser = New-MgUser @params\nWrite-Host \"User created: $($newUser.UserPrincipalName)\" -ForegroundColor Green\nWrite-Host \"Temp password: $tempPassword\" -ForegroundColor Yellow", + "label": "Create M365 user" + } + ], + "expected_outcome": "User account created. **Save the temporary password securely** — you'll need it for the welcome email.", + }, + { + "id": "step_3", + "type": "procedure_step", + "title": "Assign License", + "content_type": "action", + "description": "Assign the **[VAR:license_type]** license to **[VAR:display_name]**.", + "commands": [ + { + "language": "powershell", + "code": "# Get the user\n$user = Get-MgUser -Filter \"userPrincipalName eq '[VAR:email_address]'\"\n\n# Get the SKU for the selected license\n# Common SKU mappings:\n# Business Basic = O365_BUSINESS_ESSENTIALS or SPB\n# Business Standard = O365_BUSINESS_PREMIUM\n# Business Premium = SPB\n# E3 = SPE_E3\n# E5 = SPE_E5\n$skus = Get-MgSubscribedSku | Where-Object { $_.PrepaidUnits.Enabled - $_.ConsumedUnits -gt 0 }\n$skus | Select-Object SkuPartNumber, SkuId | Format-Table\n\n# Assign license (replace SKU_ID with actual value from above)\n# Set-MgUserLicense -UserId $user.Id -AddLicenses @(@{SkuId = \"SKU_ID_HERE\"}) -RemoveLicenses @()", + "label": "Assign license via PowerShell" + } + ], + "expected_outcome": "License assigned. Mailbox provisioning may take 5-15 minutes.", + "verification": { + "type": "checkbox", + "prompt": "License successfully assigned?" + }, + }, + { + "id": "step_4", + "type": "procedure_step", + "title": "Wait for Mailbox Provisioning", + "content_type": "informational", + "description": "Exchange Online mailbox provisioning typically takes 5-15 minutes after license assignment.\n\nWhile waiting, you can proceed to configure groups in the next step.\n\nTo check mailbox status:", + "commands": [ + { + "language": "powershell", + "code": "# Check if mailbox exists yet\nConnect-ExchangeOnline\nGet-EXOMailbox -Identity \"[VAR:email_address]\" -ErrorAction SilentlyContinue | Select-Object DisplayName, PrimarySmtpAddress, RecipientTypeDetails", + "label": "Check mailbox provisioning" + } + ], + "expected_outcome": "Mailbox appears with `RecipientTypeDetails` = `UserMailbox`.", + }, + { + "id": "step_5", + "type": "procedure_step", + "title": "Add to Security Groups", + "content_type": "action", + "description": "Add **[VAR:display_name]** to the requested security and distribution groups.\n\n**Requested groups:**\n[VAR:security_groups]", + "commands": [ + { + "language": "powershell", + "code": "# Get the user\n$user = Get-MgUser -Filter \"userPrincipalName eq '[VAR:email_address]'\"\n\n# List available groups (search by name)\n# Get-MgGroup -Filter \"displayName eq 'GROUP_NAME'\" | Select-Object DisplayName, Id\n\n# Add user to a group\n# New-MgGroupMember -GroupId \"GROUP_ID\" -DirectoryObjectId $user.Id", + "label": "Add to groups" + } + ], + "expected_outcome": "User appears as a member of all requested groups.", + "verification": { + "type": "checkbox", + "prompt": "User added to all requested groups?" + }, + }, + { + "id": "step_6", + "type": "procedure_step", + "title": "Configure Shared Mailbox Access", + "content_type": "action", + "description": "Grant **[VAR:display_name]** Full Access and Send As permissions on requested shared mailboxes.\n\n**Requested shared mailboxes:**\n[VAR:shared_mailboxes]", + "commands": [ + { + "language": "powershell", + "code": "# Grant Full Access to shared mailbox\n# Add-MailboxPermission -Identity \"SHARED_MAILBOX@company.com\" -User \"[VAR:email_address]\" -AccessRights FullAccess -AutoMapping $true\n\n# Grant Send As permission\n# Add-RecipientPermission -Identity \"SHARED_MAILBOX@company.com\" -Trustee \"[VAR:email_address]\" -AccessRights SendAs -Confirm:$false", + "label": "Configure shared mailbox access" + } + ], + "expected_outcome": "Shared mailboxes auto-map in Outlook within 30-60 minutes.", + }, + { + "id": "step_7", + "type": "procedure_step", + "title": "Set Manager (Optional)", + "content_type": "action", + "description": "Set the manager for **[VAR:display_name]** if provided.\n\nManager: **[VAR:manager_email]**", + "commands": [ + { + "language": "powershell", + "code": "# Set manager\n$user = Get-MgUser -Filter \"userPrincipalName eq '[VAR:email_address]'\"\n$manager = Get-MgUser -Filter \"userPrincipalName eq '[VAR:manager_email]'\"\n\nif ($manager) {\n Set-MgUserManagerByRef -UserId $user.Id -BodyParameter @{ \"@odata.id\" = \"https://graph.microsoft.com/v1.0/users/$($manager.Id)\" }\n Write-Host \"Manager set successfully\" -ForegroundColor Green\n}", + "label": "Set manager" + } + ], + }, + { + "id": "step_8", + "type": "procedure_step", + "title": "Configure MFA", + "content_type": "action", + "description": "Ensure MFA is configured for **[VAR:display_name]**.\n\nIf the tenant uses **Security Defaults** or **Conditional Access**, MFA will be prompted on first login.\n\nIf using per-user MFA, enable it manually:\n1. Go to Microsoft Entra admin center > Users > Per-user MFA\n2. Find **[VAR:email_address]**\n3. Set MFA status to **Enabled**\n\nThe user will be prompted to set up the Microsoft Authenticator app on first login.", + "verification": { + "type": "checkbox", + "prompt": "MFA configured or will be prompted on first sign-in?" + }, + }, + { + "id": "step_9", + "type": "procedure_step", + "title": "Send Welcome Email & Credentials", + "content_type": "informational", + "description": "Send the new user their login credentials securely.\n\n**Account details to communicate:**\n- Email: **[VAR:email_address]**\n- Temporary password: *(from step 2)*\n- Sign-in URL: https://portal.office.com\n- They will be prompted to change their password on first login\n- They will be prompted to set up MFA (Microsoft Authenticator app)\n\n**Best practice:** Send the username and password via separate channels (e.g., email the username to the manager, text the password to the user's personal phone).", + "verification": { + "type": "checkbox", + "prompt": "Welcome email/credentials sent securely?" + }, + }, + { + "id": "step_10", + "type": "procedure_step", + "title": "Document & Update Ticket", + "content_type": "informational", + "description": "Update the documentation and close the ticket.\n\n**Ticket: [VAR:ticket_number]**\n\n| Item | Value |\n|------|-------|\n| User | **[VAR:display_name]** |\n| Email | **[VAR:email_address]** |\n| License | **[VAR:license_type]** |\n| Department | **[VAR:department]** |\n| Title | **[VAR:job_title]** |\n| Start Date | **[VAR:start_date]** |\n| Groups | [VAR:security_groups] |\n| Shared Mailboxes | [VAR:shared_mailboxes] |", + "verification": { + "type": "checkbox", + "prompt": "Ticket updated and documented?" + }, + }, + { + "id": "step_end", + "type": "procedure_end", + "title": "User Onboarding Complete", + "description": "**[VAR:display_name]** ([VAR:email_address]) has been onboarded to Microsoft 365 for **[VAR:client_name]**.\n\n**Summary:**\n- Account created with **[VAR:license_type]**\n- Added to security groups\n- Shared mailbox access configured\n- MFA will be prompted on first sign-in\n\n**Follow-up in 24 hours:**\n- Verify user has logged in successfully\n- Confirm MFA is registered\n- Check shared mailboxes auto-mapped in Outlook", + }, + ] + }, + } + + +def get_vpn_gateway_flow() -> dict[str, Any]: + """Site-to-Site VPN Configuration — firewall/network setup procedure.""" + return { + "name": "Site-to-Site VPN Configuration", + "description": "Step-by-step procedure for configuring a site-to-site IPSec VPN tunnel between two locations. Covers network planning, firewall configuration, tunnel setup, and verification.", + "tree_type": "procedural", + "category": "Projects - Networking", + "tags": ["vpn", "ipsec", "networking", "firewall"], + "intake_form": [ + { + "variable_name": "site_a_name", + "label": "Site A Name (Primary)", + "field_type": "text", + "required": True, + "placeholder": "e.g. HQ Office", + "group_name": "Site A — Primary", + "display_order": 1, + }, + { + "variable_name": "site_a_public_ip", + "label": "Site A Public IP", + "field_type": "ip_address", + "required": True, + "placeholder": "e.g. 203.0.113.10", + "group_name": "Site A — Primary", + "display_order": 2, + }, + { + "variable_name": "site_a_lan_subnet", + "label": "Site A LAN Subnet", + "field_type": "text", + "required": True, + "placeholder": "e.g. 10.0.1.0/24", + "help_text": "Local network behind Site A's firewall", + "group_name": "Site A — Primary", + "display_order": 3, + }, + { + "variable_name": "site_a_firewall", + "label": "Site A Firewall Model", + "field_type": "select", + "required": True, + "options": ["SonicWall", "Fortinet FortiGate", "Meraki MX", "pfSense/OPNsense", "Ubiquiti USG/UDM", "Other"], + "group_name": "Site A — Primary", + "display_order": 4, + }, + { + "variable_name": "site_b_name", + "label": "Site B Name (Remote)", + "field_type": "text", + "required": True, + "placeholder": "e.g. Branch Office", + "group_name": "Site B — Remote", + "display_order": 5, + }, + { + "variable_name": "site_b_public_ip", + "label": "Site B Public IP", + "field_type": "ip_address", + "required": True, + "placeholder": "e.g. 198.51.100.20", + "group_name": "Site B — Remote", + "display_order": 6, + }, + { + "variable_name": "site_b_lan_subnet", + "label": "Site B LAN Subnet", + "field_type": "text", + "required": True, + "placeholder": "e.g. 10.0.2.0/24", + "help_text": "Local network behind Site B's firewall", + "group_name": "Site B — Remote", + "display_order": 7, + }, + { + "variable_name": "site_b_firewall", + "label": "Site B Firewall Model", + "field_type": "select", + "required": True, + "options": ["SonicWall", "Fortinet FortiGate", "Meraki MX", "pfSense/OPNsense", "Ubiquiti USG/UDM", "Other"], + "group_name": "Site B — Remote", + "display_order": 8, + }, + { + "variable_name": "psk", + "label": "Pre-Shared Key", + "field_type": "password", + "required": True, + "help_text": "IKE pre-shared key — minimum 20 characters recommended. Store in password vault.", + "group_name": "Tunnel Configuration", + "display_order": 9, + }, + { + "variable_name": "encryption", + "label": "Encryption Algorithm", + "field_type": "select", + "required": True, + "options": ["AES-256-GCM (recommended)", "AES-256-CBC", "AES-128-GCM", "AES-128-CBC"], + "default_value": "AES-256-GCM (recommended)", + "group_name": "Tunnel Configuration", + "display_order": 10, + }, + { + "variable_name": "ike_version", + "label": "IKE Version", + "field_type": "select", + "required": True, + "options": ["IKEv2 (recommended)", "IKEv1"], + "default_value": "IKEv2 (recommended)", + "group_name": "Tunnel Configuration", + "display_order": 11, + }, + { + "variable_name": "client_name", + "label": "Client Name", + "field_type": "text", + "required": True, + "placeholder": "e.g. Contoso Ltd", + "group_name": "Project Info", + "display_order": 12, + }, + { + "variable_name": "ticket_number", + "label": "Ticket / Project Number", + "field_type": "text", + "required": False, + "placeholder": "e.g. PRJ-2024-0099", + "group_name": "Project Info", + "display_order": 13, + }, + ], + "tree_structure": { + "steps": [ + { + "id": "step_1", + "type": "procedure_step", + "title": "Verify Network Prerequisites", + "content_type": "verification", + "description": "Verify the following before configuring the VPN for **[VAR:client_name]** (ticket: **[VAR:ticket_number]**):\n\n**Site A — [VAR:site_a_name]:**\n- Public IP: **[VAR:site_a_public_ip]** (static, not behind NAT if possible)\n- LAN Subnet: **[VAR:site_a_lan_subnet]**\n- Firewall: **[VAR:site_a_firewall]**\n\n**Site B — [VAR:site_b_name]:**\n- Public IP: **[VAR:site_b_public_ip]** (static, not behind NAT if possible)\n- LAN Subnet: **[VAR:site_b_lan_subnet]**\n- Firewall: **[VAR:site_b_firewall]**\n\n**Critical checks:**\n- LAN subnets must NOT overlap\n- Both firewalls support IPSec VPN\n- ISPs are not blocking UDP 500 and 4500\n- Both sites have admin access to their firewall", + "verification": { + "type": "checkbox", + "prompt": "All prerequisites verified — no subnet overlap, admin access confirmed?" + }, + }, + { + "id": "step_2", + "type": "procedure_step", + "title": "Document the Tunnel Configuration", + "content_type": "informational", + "description": "Record the agreed tunnel parameters before configuring either side:\n\n| Parameter | Value |\n|-----------|-------|\n| IKE Version | **[VAR:ike_version]** |\n| Encryption | **[VAR:encryption]** |\n| Hash | SHA-256 |\n| DH Group | 14 (2048-bit) |\n| SA Lifetime | Phase 1: 28800s / Phase 2: 3600s |\n| PFS | Enabled (DH Group 14) |\n| Site A Peer | **[VAR:site_a_public_ip]** |\n| Site B Peer | **[VAR:site_b_public_ip]** |\n| Site A Network | **[VAR:site_a_lan_subnet]** |\n| Site B Network | **[VAR:site_b_lan_subnet]** |\n\n**Both sides MUST match exactly.** Save this table — you'll reference it during configuration.", + }, + { + "id": "step_3", + "type": "procedure_step", + "title": "Configure Site A Firewall", + "content_type": "action", + "description": "Configure the VPN tunnel on **[VAR:site_a_name]** (**[VAR:site_a_firewall]**).\n\nLog into the firewall at **[VAR:site_a_name]** and create a new site-to-site VPN policy:\n\n1. **Phase 1 (IKE SA):**\n - Remote gateway: **[VAR:site_b_public_ip]**\n - Authentication: Pre-shared key\n - IKE version: **[VAR:ike_version]**\n - Encryption: **[VAR:encryption]**\n - Hash: SHA-256\n - DH Group: 14\n - Lifetime: 28800 seconds\n\n2. **Phase 2 (IPSec SA):**\n - Local network: **[VAR:site_a_lan_subnet]**\n - Remote network: **[VAR:site_b_lan_subnet]**\n - Encryption: **[VAR:encryption]**\n - Hash: SHA-256\n - PFS: Group 14\n - Lifetime: 3600 seconds\n\n3. **Firewall rules:**\n - Allow traffic from **[VAR:site_a_lan_subnet]** to **[VAR:site_b_lan_subnet]** via VPN zone\n - Allow traffic from **[VAR:site_b_lan_subnet]** to **[VAR:site_a_lan_subnet]** via VPN zone", + "verification": { + "type": "checkbox", + "prompt": "Site A VPN policy and firewall rules configured?" + }, + }, + { + "id": "step_4", + "type": "procedure_step", + "title": "Configure Site B Firewall", + "content_type": "action", + "description": "Configure the matching VPN tunnel on **[VAR:site_b_name]** (**[VAR:site_b_firewall]**).\n\nRepeat the same configuration, but mirror the local/remote settings:\n\n1. **Phase 1 (IKE SA):**\n - Remote gateway: **[VAR:site_a_public_ip]**\n - All other settings IDENTICAL to Site A\n\n2. **Phase 2 (IPSec SA):**\n - Local network: **[VAR:site_b_lan_subnet]**\n - Remote network: **[VAR:site_a_lan_subnet]**\n - All other settings IDENTICAL to Site A\n\n3. **Firewall rules:**\n - Allow traffic from **[VAR:site_b_lan_subnet]** to **[VAR:site_a_lan_subnet]** via VPN zone\n - Allow traffic from **[VAR:site_a_lan_subnet]** to **[VAR:site_b_lan_subnet]** via VPN zone\n\n**Double check:** Encryption, hash, DH group, PFS, and lifetimes must be identical on both sides.", + "verification": { + "type": "checkbox", + "prompt": "Site B VPN policy and firewall rules configured (matching Site A)?" + }, + }, + { + "id": "step_5", + "type": "procedure_step", + "title": "Initiate Tunnel & Verify Phase 1", + "content_type": "verification", + "description": "Bring up the VPN tunnel and verify Phase 1 (IKE) negotiation succeeds.\n\nOn most firewalls, you can force the tunnel to initiate by:\n- Sending a ping from one LAN to the other\n- Using the firewall's \"Connect\" or \"Bring Up\" button in the VPN status page\n\n**Check the VPN status page on both firewalls:**\n- Phase 1 should show **Established** or **Connected**\n- If Phase 1 fails, check: pre-shared key match, IKE version match, encryption/hash match, public IPs correct\n\n**Common Phase 1 failures:**\n- Mismatched PSK (most common)\n- ISP blocking UDP 500/4500\n- NAT-T issues (enable NAT traversal if behind NAT)\n- DH group mismatch", + "verification": { + "type": "checkbox", + "prompt": "Phase 1 (IKE) established on both firewalls?" + }, + }, + { + "id": "step_6", + "type": "procedure_step", + "title": "Verify Phase 2 & Traffic Flow", + "content_type": "verification", + "description": "Verify Phase 2 (IPSec) is established and traffic flows between sites.\n\n**From a device at [VAR:site_a_name] ([VAR:site_a_lan_subnet]):**\n- Ping a device at [VAR:site_b_name] ([VAR:site_b_lan_subnet])\n- Try accessing a file share or RDP session across the tunnel\n\n**From a device at [VAR:site_b_name] ([VAR:site_b_lan_subnet]):**\n- Ping a device at [VAR:site_a_name] ([VAR:site_a_lan_subnet])\n\n**Common Phase 2 failures:**\n- Mismatched encryption/hash/PFS settings\n- Incorrect local/remote network definitions (proxy IDs)\n- Firewall rules not allowing VPN zone traffic\n- Asymmetric routing (traffic going out default route instead of tunnel)", + "verification": { + "type": "checkbox", + "prompt": "Bidirectional traffic confirmed between sites?" + }, + }, + { + "id": "step_7", + "type": "procedure_step", + "title": "Test Critical Services", + "content_type": "verification", + "description": "Test the specific services that need to work across the VPN:\n\n- **File shares:** Access shared folders from remote site\n- **RDP:** Remote desktop to servers across the tunnel\n- **Active Directory:** If domain-joined, verify domain auth works from remote site\n- **DNS:** Resolve internal hostnames across the tunnel\n- **Printing:** If printers need cross-site access\n\nTest from both directions (A→B and B→A).", + "verification": { + "type": "checkbox", + "prompt": "All required services working bidirectionally?" + }, + }, + { + "id": "step_8", + "type": "procedure_step", + "title": "Configure Monitoring & Alerts", + "content_type": "action", + "description": "Set up monitoring to detect VPN tunnel drops:\n\n1. **On the firewalls:** Enable VPN tunnel status alerting (email/SNMP)\n2. **In your RMM/monitoring tool:** Add a ping monitor from each site to a host on the other site\n3. **Recommended thresholds:**\n - Alert if tunnel is down for > 5 minutes\n - Alert if latency exceeds 100ms\n - Alert if packet loss exceeds 1%\n\n**DPD (Dead Peer Detection):**\nEnsure DPD is enabled on both firewalls with a 10-second interval. This allows automatic tunnel renegotiation after brief outages.", + "verification": { + "type": "checkbox", + "prompt": "Monitoring and DPD configured?" + }, + }, + { + "id": "step_9", + "type": "procedure_step", + "title": "Document & Update Password Vault", + "content_type": "informational", + "description": "Document the complete VPN configuration:\n\n| Parameter | Site A | Site B |\n|-----------|--------|--------|\n| Name | **[VAR:site_a_name]** | **[VAR:site_b_name]** |\n| Public IP | **[VAR:site_a_public_ip]** | **[VAR:site_b_public_ip]** |\n| LAN Subnet | **[VAR:site_a_lan_subnet]** | **[VAR:site_b_lan_subnet]** |\n| Firewall | **[VAR:site_a_firewall]** | **[VAR:site_b_firewall]** |\n| IKE Version | **[VAR:ike_version]** | **[VAR:ike_version]** |\n| Encryption | **[VAR:encryption]** | **[VAR:encryption]** |\n\n**Store in password vault:**\n- Pre-shared key\n- Firewall admin credentials for both sites\n\n**Update:**\n- Client network diagram with VPN tunnel\n- Firewall configuration backup on both sites", + "verification": { + "type": "checkbox", + "prompt": "Documentation complete and PSK stored in vault?" + }, + }, + { + "id": "step_end", + "type": "procedure_end", + "title": "VPN Tunnel Active", + "description": "Site-to-site VPN tunnel is established between **[VAR:site_a_name]** and **[VAR:site_b_name]** for **[VAR:client_name]**.\n\n**Summary:**\n- Tunnel: **[VAR:site_a_public_ip]** ↔ **[VAR:site_b_public_ip]**\n- Networks: **[VAR:site_a_lan_subnet]** ↔ **[VAR:site_b_lan_subnet]**\n- Encryption: **[VAR:encryption]** / **[VAR:ike_version]**\n\n**Follow-up:**\n- Verify tunnel stability over 24-48 hours\n- Confirm monitoring alerts fire correctly (test with a brief tunnel disconnect)\n- Schedule quarterly PSK rotation", + }, + ] + }, + } + + +# ============================================================================= +# SEEDING FUNCTIONS +# ============================================================================= + +async def get_admin_token(client: httpx.AsyncClient) -> str: + """Authenticate and get admin token.""" + response = await client.post( + f"{API_BASE_URL}/auth/login", + data={"username": ADMIN_EMAIL, "password": ADMIN_PASSWORD}, + ) + if response.status_code != 200: + raise Exception(f"Login failed ({response.status_code}): {response.text}") + return response.json()["access_token"] + + +async def create_procedural_flow(client: httpx.AsyncClient, token: str, flow_data: dict) -> dict | None: + """Create a procedural flow via the API. Returns None if it already exists.""" + headers = {"Authorization": f"Bearer {token}"} + + # Mark as default/system flow (public and visible to all) + flow_data["is_default"] = True + flow_data["is_public"] = True + + # Check if flow with same name exists + list_response = await client.get(f"{API_BASE_URL}/trees", headers=headers, params={"tree_type": "procedural"}) + if list_response.status_code == 200: + existing = list_response.json() + for tree in existing: + if tree["name"] == flow_data["name"]: + if not tree.get("is_public") or not tree.get("is_default"): + patch_response = await client.put( + f"{API_BASE_URL}/trees/{tree['id']}", + json={"is_public": True, "is_default": True}, + headers=headers, + ) + if patch_response.status_code == 200: + print(f" [UPDATE] Flow '{flow_data['name']}' visibility updated (ID: {tree['id']})") + return None + print(f" [SKIP] Flow '{flow_data['name']}' already exists (ID: {tree['id']})") + return None + + # Create the flow + response = await client.post( + f"{API_BASE_URL}/trees", + json=flow_data, + headers=headers, + ) + + if response.status_code not in (200, 201): + raise Exception(f"Failed to create flow '{flow_data['name']}': {response.text}") + + tree = response.json() + print(f" [OK] Created flow '{flow_data['name']}' (ID: {tree['id']})") + return tree + + +async def seed_procedural_flows(): + """Main seeding function.""" + print("\n" + "=" * 60) + print(" PATHERLY - Procedural Flow Templates Seeder") + print("=" * 60) + + async with httpx.AsyncClient(timeout=60.0) as client: + # Health check + try: + health_check = await client.get(f"{API_BASE_URL.replace('/api/v1', '')}/health") + if health_check.status_code != 200: + print(f"\n[ERROR] API health check failed: {health_check.status_code}") + return False + except httpx.ConnectError: + print("\n[ERROR] Cannot connect to API server") + print(f" Make sure the server is running at {API_BASE_URL}") + return False + + # Authenticate + print("\n[1/3] Authenticating...") + try: + token = await get_admin_token(client) + print(f" Logged in as {ADMIN_EMAIL}") + except Exception as e: + print(f" [ERROR] Failed to authenticate: {e}") + return False + + # Get flow definitions + print("\n[2/3] Preparing procedural flows...") + flows_to_create = [ + ("Projects - Infrastructure", get_domain_controller_flow()), + ("Projects - Microsoft 365", get_m365_user_onboarding_flow()), + ("Projects - Networking", get_vpn_gateway_flow()), + ] + print(f" Found {len(flows_to_create)} flows to seed\n") + + # Create flows + print("[3/3] Creating procedural flows...") + created_count = 0 + skipped_count = 0 + + current_category = None + for category, flow_data in flows_to_create: + if category != current_category: + print(f"\n {category}:") + current_category = category + + try: + result = await create_procedural_flow(client, token, flow_data) + if result: + created_count += 1 + else: + skipped_count += 1 + except Exception as e: + print(f" [FAIL] Failed to create '{flow_data['name']}': {e}") + + # Summary + print("\n" + "=" * 60) + print(" SEEDING COMPLETE") + print("=" * 60) + print(f" Flows created: {created_count}") + print(f" Flows skipped (already exist): {skipped_count}") + print() + + return True + + +def main(): + global ADMIN_EMAIL, ADMIN_PASSWORD + + parser = argparse.ArgumentParser(description="Seed procedural flow templates") + parser.add_argument("--email", required=True, help="Admin email for authentication") + parser.add_argument("--password", required=True, help="Admin password") + args = parser.parse_args() + + ADMIN_EMAIL = args.email + ADMIN_PASSWORD = args.password + + asyncio.run(seed_procedural_flows()) + + +if __name__ == "__main__": + main() diff --git a/backend/tests/test_procedural_flows.py b/backend/tests/test_procedural_flows.py new file mode 100644 index 00000000..c35d3855 --- /dev/null +++ b/backend/tests/test_procedural_flows.py @@ -0,0 +1,710 @@ +"""Tests for procedural flows feature (tree_type='procedural').""" +import pytest +from app.core.tree_validation import ( + validate_procedural_structure, + can_publish_tree, +) +from app.schemas.tree import IntakeFormField, TreeCreate + + +# --- Helper Data --- + +def make_valid_procedural_steps(): + """Return a valid procedural step array.""" + return { + "steps": [ + { + "id": "step-1", + "type": "procedure_step", + "title": "Configure Static IP", + "description": "Set the IP to [VAR:ip_address]", + "content_type": "action", + }, + { + "id": "step-2", + "type": "procedure_step", + "title": "Install AD DS Role", + "description": "Add the AD DS server role", + "content_type": "action", + }, + { + "id": "step-end", + "type": "procedure_end", + "title": "Procedure Complete", + }, + ] + } + + +def make_valid_intake_form(): + """Return valid intake form field dicts.""" + return [ + { + "variable_name": "server_name", + "label": "Server Name", + "field_type": "text", + "required": True, + "display_order": 1, + }, + { + "variable_name": "ip_address", + "label": "IP Address", + "field_type": "ip_address", + "required": True, + "display_order": 2, + }, + { + "variable_name": "notes", + "label": "Additional Notes", + "field_type": "textarea", + "required": False, + "display_order": 3, + }, + ] + + +# --- Procedural Validation Unit Tests --- + +class TestValidateProceduralStructure: + """Unit tests for validate_procedural_structure().""" + + def test_valid_procedural_tree(self): + is_valid, errors = validate_procedural_structure(make_valid_procedural_steps()) + assert is_valid + assert errors == [] + + def test_empty_structure(self): + is_valid, errors = validate_procedural_structure({}) + assert not is_valid + assert any("empty" in e["message"].lower() for e in errors) + + def test_none_structure(self): + is_valid, errors = validate_procedural_structure(None) + assert not is_valid + + def test_missing_steps_array(self): + is_valid, errors = validate_procedural_structure({"name": "test"}) + assert not is_valid + assert any("steps" in e["message"] for e in errors) + + def test_empty_steps_array(self): + is_valid, errors = validate_procedural_structure({"steps": []}) + assert not is_valid + + def test_step_missing_id(self): + structure = { + "steps": [ + {"type": "procedure_step", "title": "Step 1"}, + {"id": "end", "type": "procedure_end", "title": "Done"}, + ] + } + is_valid, errors = validate_procedural_structure(structure) + assert not is_valid + assert any("id" in e["field"] for e in errors) + + def test_step_missing_type(self): + structure = { + "steps": [ + {"id": "s1", "title": "Step 1"}, + {"id": "end", "type": "procedure_end", "title": "Done"}, + ] + } + is_valid, errors = validate_procedural_structure(structure) + assert not is_valid + assert any("type" in e["field"] for e in errors) + + def test_step_missing_title(self): + structure = { + "steps": [ + {"id": "s1", "type": "procedure_step"}, + {"id": "end", "type": "procedure_end", "title": "Done"}, + ] + } + is_valid, errors = validate_procedural_structure(structure) + assert not is_valid + assert any("title" in e["field"] for e in errors) + + def test_invalid_step_type(self): + structure = { + "steps": [ + {"id": "s1", "type": "decision", "title": "Bad Type"}, + {"id": "end", "type": "procedure_end", "title": "Done"}, + ] + } + is_valid, errors = validate_procedural_structure(structure) + assert not is_valid + assert any("Invalid step type" in e["message"] for e in errors) + + def test_no_end_step(self): + structure = { + "steps": [ + {"id": "s1", "type": "procedure_step", "title": "Step 1"}, + {"id": "s2", "type": "procedure_step", "title": "Step 2"}, + ] + } + is_valid, errors = validate_procedural_structure(structure) + assert not is_valid + assert any("procedure_end" in e["message"] for e in errors) + + def test_multiple_end_steps(self): + structure = { + "steps": [ + {"id": "s1", "type": "procedure_step", "title": "Step 1"}, + {"id": "end1", "type": "procedure_end", "title": "End 1"}, + {"id": "end2", "type": "procedure_end", "title": "End 2"}, + ] + } + is_valid, errors = validate_procedural_structure(structure) + assert not is_valid + + def test_end_step_not_last(self): + structure = { + "steps": [ + {"id": "end", "type": "procedure_end", "title": "End"}, + {"id": "s1", "type": "procedure_step", "title": "Step 1"}, + ] + } + is_valid, errors = validate_procedural_structure(structure) + assert not is_valid + assert any("last step" in e["message"] for e in errors) + + def test_duplicate_step_ids(self): + structure = { + "steps": [ + {"id": "s1", "type": "procedure_step", "title": "Step 1"}, + {"id": "s1", "type": "procedure_step", "title": "Step 2"}, + {"id": "end", "type": "procedure_end", "title": "Done"}, + ] + } + is_valid, errors = validate_procedural_structure(structure) + assert not is_valid + assert any("Duplicate" in e["message"] for e in errors) + + def test_invalid_content_type(self): + structure = { + "steps": [ + {"id": "s1", "type": "procedure_step", "title": "Step 1", "content_type": "bad_type"}, + {"id": "end", "type": "procedure_end", "title": "Done"}, + ] + } + is_valid, errors = validate_procedural_structure(structure) + assert not is_valid + assert any("content_type" in e["field"] for e in errors) + + def test_valid_content_types(self): + structure = { + "steps": [ + {"id": "s1", "type": "procedure_step", "title": "Step 1", "content_type": "action"}, + {"id": "s2", "type": "procedure_step", "title": "Step 2", "content_type": "informational"}, + {"id": "s3", "type": "procedure_step", "title": "Step 3", "content_type": "verification"}, + {"id": "s4", "type": "procedure_step", "title": "Step 4", "content_type": "warning"}, + {"id": "end", "type": "procedure_end", "title": "Done"}, + ] + } + is_valid, errors = validate_procedural_structure(structure) + assert is_valid + + def test_single_step_with_end(self): + """Minimal valid procedural tree: one step + end.""" + structure = { + "steps": [ + {"id": "s1", "type": "procedure_step", "title": "Only Step"}, + {"id": "end", "type": "procedure_end", "title": "Done"}, + ] + } + is_valid, errors = validate_procedural_structure(structure) + assert is_valid + + +# --- can_publish_tree dispatch --- + +class TestCanPublishTreeDispatch: + """Test can_publish_tree dispatches correctly by tree_type.""" + + def test_troubleshooting_uses_tree_validation(self): + """Default tree_type uses troubleshooting validation.""" + structure = { + "id": "root", + "type": "decision", + "question": "Test?", + "children": [ + {"id": "y", "type": "solution", "solution": "Yes"}, + {"id": "n", "type": "solution", "solution": "No"}, + ] + } + can, errors = can_publish_tree(structure, "My Tree", tree_type="troubleshooting") + assert can + + def test_procedural_uses_procedural_validation(self): + can, errors = can_publish_tree( + make_valid_procedural_steps(), + "DC Build Procedure", + tree_type="procedural", + ) + assert can + + def test_procedural_rejects_troubleshooting_structure(self): + """A troubleshooting structure should fail procedural validation.""" + ts_structure = { + "id": "root", + "type": "decision", + "question": "Test?", + } + can, errors = can_publish_tree(ts_structure, "My Tree", tree_type="procedural") + assert not can + + def test_procedural_validates_intake_form(self): + can, errors = can_publish_tree( + make_valid_procedural_steps(), + "DC Build", + tree_type="procedural", + intake_form=make_valid_intake_form(), + ) + assert can + + def test_procedural_rejects_duplicate_variable_names(self): + intake = [ + {"variable_name": "name", "label": "Name", "field_type": "text", "required": True, "display_order": 1}, + {"variable_name": "name", "label": "Name 2", "field_type": "text", "required": False, "display_order": 2}, + ] + can, errors = can_publish_tree( + make_valid_procedural_steps(), + "DC Build", + tree_type="procedural", + intake_form=intake, + ) + assert not can + assert any("Duplicate" in e["message"] for e in errors) + + def test_procedural_rejects_select_without_options(self): + intake = [ + {"variable_name": "role", "label": "Server Role", "field_type": "select", "required": True, "display_order": 1}, + ] + can, errors = can_publish_tree( + make_valid_procedural_steps(), + "DC Build", + tree_type="procedural", + intake_form=intake, + ) + assert not can + assert any("option" in e["message"].lower() for e in errors) + + def test_empty_name_blocks_publish(self): + can, errors = can_publish_tree( + make_valid_procedural_steps(), + "", + tree_type="procedural", + ) + assert not can + assert any("name" in e["field"] for e in errors) + + +# --- IntakeFormField Pydantic Schema Tests --- + +class TestIntakeFormFieldSchema: + """Test IntakeFormField Pydantic validation.""" + + def test_valid_text_field(self): + field = IntakeFormField( + variable_name="server_name", + label="Server Name", + field_type="text", + required=True, + display_order=1, + ) + assert field.variable_name == "server_name" + + def test_invalid_variable_name_uppercase(self): + with pytest.raises(Exception): + IntakeFormField( + variable_name="ServerName", + label="Server Name", + field_type="text", + required=True, + display_order=1, + ) + + def test_invalid_variable_name_starts_with_number(self): + with pytest.raises(Exception): + IntakeFormField( + variable_name="1server", + label="Server Name", + field_type="text", + required=True, + display_order=1, + ) + + def test_valid_variable_name_with_underscores(self): + field = IntakeFormField( + variable_name="ip_address_v4", + label="IPv4 Address", + field_type="ip_address", + required=True, + display_order=1, + ) + assert field.variable_name == "ip_address_v4" + + def test_select_requires_options(self): + with pytest.raises(Exception): + IntakeFormField( + variable_name="role", + label="Role", + field_type="select", + required=True, + display_order=1, + ) + + def test_select_with_options_valid(self): + field = IntakeFormField( + variable_name="role", + label="Role", + field_type="select", + required=True, + options=["AD DS", "DNS", "DHCP"], + display_order=1, + ) + assert field.options == ["AD DS", "DNS", "DHCP"] + + def test_multi_select_requires_options(self): + with pytest.raises(Exception): + IntakeFormField( + variable_name="roles", + label="Roles", + field_type="multi_select", + required=True, + display_order=1, + ) + + def test_checkbox_field(self): + field = IntakeFormField( + variable_name="confirm_backup", + label="Backup confirmed?", + field_type="checkbox", + required=False, + display_order=1, + ) + assert field.field_type == "checkbox" + + +# --- TreeCreate Schema with Procedural Fields --- + +class TestTreeCreateProceduralSchema: + """Test TreeCreate schema with tree_type and intake_form.""" + + def test_defaults_to_troubleshooting(self): + tree = TreeCreate( + name="Test", + tree_structure={"id": "root", "type": "decision", "question": "Test?"}, + ) + assert tree.tree_type == "troubleshooting" + assert tree.intake_form is None + + def test_procedural_with_intake_form(self): + tree = TreeCreate( + name="DC Build", + tree_type="procedural", + tree_structure=make_valid_procedural_steps(), + intake_form=[ + IntakeFormField( + variable_name="server_name", + label="Server Name", + field_type="text", + required=True, + display_order=1, + ), + ], + ) + assert tree.tree_type == "procedural" + assert len(tree.intake_form) == 1 + + def test_duplicate_variable_names_rejected(self): + with pytest.raises(Exception): + TreeCreate( + name="DC Build", + tree_type="procedural", + tree_structure=make_valid_procedural_steps(), + intake_form=[ + IntakeFormField( + variable_name="name", + label="Name", + field_type="text", + required=True, + display_order=1, + ), + IntakeFormField( + variable_name="name", + label="Name Again", + field_type="text", + required=False, + display_order=2, + ), + ], + ) + + +# --- API Integration Tests --- + +@pytest.mark.asyncio +class TestProceduralFlowsAPI: + """Integration tests for procedural flow CRUD via API.""" + + async def test_create_procedural_draft(self, client, auth_headers): + """Create a procedural flow as draft.""" + tree_data = { + "name": "DC Build Procedure", + "description": "Domain Controller setup procedure", + "tree_type": "procedural", + "status": "draft", + "tree_structure": make_valid_procedural_steps(), + "intake_form": make_valid_intake_form(), + } + response = await client.post( + "/api/v1/trees", + json=tree_data, + headers=auth_headers, + ) + assert response.status_code == 200 or response.status_code == 201 + data = response.json() + assert data["tree_type"] == "procedural" + assert data["intake_form"] is not None + assert len(data["intake_form"]) == 3 + + async def test_create_procedural_published(self, client, auth_headers): + """Create a procedural flow and publish it (validation runs).""" + tree_data = { + "name": "M365 User Onboarding", + "tree_type": "procedural", + "status": "published", + "tree_structure": make_valid_procedural_steps(), + } + response = await client.post( + "/api/v1/trees", + json=tree_data, + headers=auth_headers, + ) + assert response.status_code == 200 or response.status_code == 201 + + async def test_create_procedural_published_invalid_fails(self, client, auth_headers): + """Publish should fail if procedural structure is invalid.""" + tree_data = { + "name": "Bad Procedure", + "tree_type": "procedural", + "status": "published", + "tree_structure": { + "steps": [ + {"id": "s1", "type": "procedure_step", "title": "Step 1"}, + # No end step + ] + }, + } + response = await client.post( + "/api/v1/trees", + json=tree_data, + headers=auth_headers, + ) + assert response.status_code == 422 + + async def test_list_trees_filter_by_type(self, client, auth_headers): + """Create both types and filter by tree_type.""" + # Create troubleshooting + await client.post( + "/api/v1/trees", + json={ + "name": "Troubleshooting Tree", + "status": "draft", + "tree_structure": {"id": "root", "type": "decision", "question": "Test?"}, + }, + headers=auth_headers, + ) + # Create procedural + await client.post( + "/api/v1/trees", + json={ + "name": "Procedure Flow", + "tree_type": "procedural", + "status": "draft", + "tree_structure": make_valid_procedural_steps(), + }, + headers=auth_headers, + ) + + # Filter by procedural + response = await client.get( + "/api/v1/trees?tree_type=procedural", + headers=auth_headers, + ) + assert response.status_code == 200 + data = response.json() + assert all(t["tree_type"] == "procedural" for t in data) + + # Filter by troubleshooting + response = await client.get( + "/api/v1/trees?tree_type=troubleshooting", + headers=auth_headers, + ) + assert response.status_code == 200 + data = response.json() + assert all(t["tree_type"] == "troubleshooting" for t in data) + + async def test_update_procedural_tree(self, client, auth_headers): + """Update a procedural tree's intake form.""" + # Create + create_resp = await client.post( + "/api/v1/trees", + json={ + "name": "Procedure", + "tree_type": "procedural", + "status": "draft", + "tree_structure": make_valid_procedural_steps(), + }, + headers=auth_headers, + ) + tree_id = create_resp.json()["id"] + + # Update with intake form + update_resp = await client.put( + f"/api/v1/trees/{tree_id}", + json={ + "intake_form": make_valid_intake_form(), + }, + headers=auth_headers, + ) + assert update_resp.status_code == 200 + data = update_resp.json() + assert data["intake_form"] is not None + + async def test_start_session_procedural_with_variables(self, client, auth_headers): + """Start a procedural session with intake form variables.""" + # Create published procedural tree with intake form + create_resp = await client.post( + "/api/v1/trees", + json={ + "name": "DC Build", + "tree_type": "procedural", + "status": "published", + "tree_structure": make_valid_procedural_steps(), + "intake_form": make_valid_intake_form(), + }, + headers=auth_headers, + ) + assert create_resp.status_code in (200, 201) + tree_id = create_resp.json()["id"] + + # Start session with variables + session_resp = await client.post( + "/api/v1/sessions", + json={ + "tree_id": tree_id, + "session_variables": { + "server_name": "DC01", + "ip_address": "192.168.1.10", + }, + }, + headers=auth_headers, + ) + assert session_resp.status_code in (200, 201) + session_data = session_resp.json() + assert session_data["session_variables"]["server_name"] == "DC01" + assert session_data["tree_snapshot"]["tree_type"] == "procedural" + + async def test_start_session_procedural_missing_required_field(self, client, auth_headers): + """Starting a procedural session without required intake fields should fail.""" + # Create published procedural tree with required intake form + create_resp = await client.post( + "/api/v1/trees", + json={ + "name": "DC Build", + "tree_type": "procedural", + "status": "published", + "tree_structure": make_valid_procedural_steps(), + "intake_form": make_valid_intake_form(), + }, + headers=auth_headers, + ) + tree_id = create_resp.json()["id"] + + # Try to start without required fields + session_resp = await client.post( + "/api/v1/sessions", + json={ + "tree_id": tree_id, + # Missing server_name and ip_address (both required) + }, + headers=auth_headers, + ) + assert session_resp.status_code == 422 + assert "Missing required" in session_resp.json()["detail"] + + async def test_start_session_procedural_optional_fields_ok(self, client, auth_headers): + """Starting a session with only required fields (optional missing) should work.""" + create_resp = await client.post( + "/api/v1/trees", + json={ + "name": "DC Build", + "tree_type": "procedural", + "status": "published", + "tree_structure": make_valid_procedural_steps(), + "intake_form": make_valid_intake_form(), + }, + headers=auth_headers, + ) + tree_id = create_resp.json()["id"] + + # Start with only required fields (notes is optional) + session_resp = await client.post( + "/api/v1/sessions", + json={ + "tree_id": tree_id, + "session_variables": { + "server_name": "DC01", + "ip_address": "192.168.1.10", + # notes is optional, not provided + }, + }, + headers=auth_headers, + ) + assert session_resp.status_code in (200, 201) + + async def test_fork_preserves_tree_type_and_intake_form(self, client, auth_headers): + """Forking a procedural tree should preserve tree_type and intake_form.""" + # Create procedural tree + create_resp = await client.post( + "/api/v1/trees", + json={ + "name": "DC Build Original", + "tree_type": "procedural", + "status": "published", + "tree_structure": make_valid_procedural_steps(), + "intake_form": make_valid_intake_form(), + }, + headers=auth_headers, + ) + tree_id = create_resp.json()["id"] + + # Fork it + fork_resp = await client.post( + f"/api/v1/trees/{tree_id}/fork", + json={"fork_reason": "Customized for Client X"}, + headers=auth_headers, + ) + assert fork_resp.status_code in (200, 201) + fork_data = fork_resp.json() + assert fork_data["tree_type"] == "procedural" + assert fork_data["intake_form"] is not None + assert len(fork_data["intake_form"]) == 3 + + async def test_existing_trees_default_troubleshooting(self, client, auth_headers): + """Trees created without tree_type should default to troubleshooting.""" + response = await client.post( + "/api/v1/trees", + json={ + "name": "Legacy Tree", + "status": "draft", + "tree_structure": {"id": "root", "type": "decision", "question": "Test?"}, + }, + headers=auth_headers, + ) + assert response.status_code in (200, 201) + data = response.json() + assert data["tree_type"] == "troubleshooting" + assert data.get("intake_form") is None diff --git a/docs/archive/2026-01-13-report-phase-c-p2.md b/docs/archive/2026-01-13-report-phase-c-p2.md new file mode 100644 index 00000000..1eca0755 --- /dev/null +++ b/docs/archive/2026-01-13-report-phase-c-p2.md @@ -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 + +``` + +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` | diff --git a/docs/archive/2026-01-13-report-phase-c-pt1.md b/docs/archive/2026-01-13-report-phase-c-pt1.md new file mode 100644 index 00000000..1eca0755 --- /dev/null +++ b/docs/archive/2026-01-13-report-phase-c-pt1.md @@ -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 + +``` + +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` | diff --git a/docs/archive/2026-02-03-draft-trees-feature.md b/docs/archive/2026-02-03-draft-trees-feature.md new file mode 100644 index 00000000..9ea8faf1 --- /dev/null +++ b/docs/archive/2026-02-03-draft-trees-feature.md @@ -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' && ( + + Draft + +)} + +// Filter toggle + +``` + +**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 ? ( + <> + + + +) : ( + +)} +``` + +**Validation Display:** + +```tsx +// Show validation summary for drafts +{isDraft && ( + +)} + +// Show validation summary for published (blocks save) +{!isDraft && validationErrors.length > 0 && ( + +)} +``` + +**Status Badge in Editor:** +```tsx +
+

{tree.name}

+ {tree.status === 'draft' && ( + + Draft + + )} +
+``` + +### 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: + + +// In StepLibraryBrowser, filter control: + +``` + +--- + +## 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 diff --git a/docs/archive/2026-02-04-feature-ideas-brainstorm.md b/docs/archive/2026-02-04-feature-ideas-brainstorm.md new file mode 100644 index 00000000..e73adfd7 --- /dev/null +++ b/docs/archive/2026-02-04-feature-ideas-brainstorm.md @@ -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* diff --git a/docs/archive/2026-02-06-project-review.md b/docs/archive/2026-02-06-project-review.md new file mode 100644 index 00000000..ce9c0735 --- /dev/null +++ b/docs/archive/2026-02-06-project-review.md @@ -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 diff --git a/docs/archive/2026-02-07-notification-system-design.md b/docs/archive/2026-02-07-notification-system-design.md new file mode 100644 index 00000000..15973216 --- /dev/null +++ b/docs/archive/2026-02-07-notification-system-design.md @@ -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 with 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: + + + + +``` + +**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 `` 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) + diff --git a/docs/archive/2026-02-10-feature-ideas-brainstorm.md b/docs/archive/2026-02-10-feature-ideas-brainstorm.md new file mode 100644 index 00000000..a12e6168 --- /dev/null +++ b/docs/archive/2026-02-10-feature-ideas-brainstorm.md @@ -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 diff --git a/docs/archive/2026-02-11-invite-codes-admin-panel.md b/docs/archive/2026-02-11-invite-codes-admin-panel.md new file mode 100644 index 00000000..d97203a8 --- /dev/null +++ b/docs/archive/2026-02-11-invite-codes-admin-panel.md @@ -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 ` + +--- + +## 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 diff --git a/docs/archive/2026-02-12-admin-invite-user-management-enhancement.md b/docs/archive/2026-02-12-admin-invite-user-management-enhancement.md new file mode 100644 index 00000000..ece18433 --- /dev/null +++ b/docs/archive/2026-02-12-admin-invite-user-management-enhancement.md @@ -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 "` +- `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. diff --git a/docs/archive/2026-02-12-admin-invite-user-mgmt.md b/docs/archive/2026-02-12-admin-invite-user-mgmt.md new file mode 100644 index 00000000..904b9f1f --- /dev/null +++ b/docs/archive/2026-02-12-admin-invite-user-mgmt.md @@ -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 | 1–90 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 "` +- 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 1–90) + +`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 178–183) + +- 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 ~178–183) | +| `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 1–90 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). diff --git a/docs/archive/2026-02-12-issue-57-command-output-capture.md b/docs/archive/2026-02-12-issue-57-command-output-capture.md new file mode 100644 index 00000000..0f31bf86 --- /dev/null +++ b/docs/archive/2026-02-12-issue-57-command-output-capture.md @@ -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 +

Commands Run: ping 8.8.8.8, tracert 8.8.8.8

+
{html.escape(output)}
+``` + +**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 `
` 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 `
` 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
diff --git a/docs/archive/2026-02-12-resend-invite-codes.md b/docs/archive/2026-02-12-resend-invite-codes.md
new file mode 100644
index 00000000..b5d3e34d
--- /dev/null
+++ b/docs/archive/2026-02-12-resend-invite-codes.md
@@ -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
diff --git a/docs/archive/2026-02-12-user-creation-password-reset.md b/docs/archive/2026-02-12-user-creation-password-reset.md
new file mode 100644
index 00000000..200fec3f
--- /dev/null
+++ b/docs/archive/2026-02-12-user-creation-password-reset.md
@@ -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`
+
+### 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)
diff --git a/docs/archive/2026-02-13-export-phase-b-design.md b/docs/archive/2026-02-13-export-phase-b-design.md
new file mode 100644
index 00000000..d98833c8
--- /dev/null
+++ b/docs/archive/2026-02-13-export-phase-b-design.md
@@ -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
+CUSTOM
+```
+
+**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 `
` with editable `