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'| {html.escape(key)} | {html.escape(value)} |
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('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 `