diff --git a/backend/alembic/versions/029_add_session_outcomes.py b/backend/alembic/versions/029_add_session_outcomes.py new file mode 100644 index 00000000..ae6a3a07 --- /dev/null +++ b/backend/alembic/versions/029_add_session_outcomes.py @@ -0,0 +1,31 @@ +"""add outcome fields to sessions + +Revision ID: 029 +Revises: 028 +Create Date: 2026-02-11 + +Adds outcome and outcome_notes columns for session completion tracking. +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "029" +down_revision: Union[str, None] = "028" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("sessions", sa.Column("outcome", sa.String(length=20), nullable=True)) + op.add_column("sessions", sa.Column("outcome_notes", sa.Text(), nullable=True)) + op.create_index(op.f("ix_sessions_outcome"), "sessions", ["outcome"], unique=False) + + +def downgrade() -> None: + op.drop_index(op.f("ix_sessions_outcome"), table_name="sessions") + op.drop_column("sessions", "outcome_notes") + op.drop_column("sessions", "outcome") diff --git a/backend/app/api/endpoints/sessions.py b/backend/app/api/endpoints/sessions.py index d411e368..0e829fdd 100644 --- a/backend/app/api/endpoints/sessions.py +++ b/backend/app/api/endpoints/sessions.py @@ -10,7 +10,16 @@ from app.core.database import get_db from app.models.tree import Tree from app.models.session import Session from app.models.user import User -from app.schemas.session import SessionCreate, SessionUpdate, SessionResponse, SessionExport, ScratchpadUpdate, SaveAsTreeRequest, SaveAsTreeResponse +from app.schemas.session import ( + SessionCreate, + SessionUpdate, + SessionResponse, + SessionExport, + ScratchpadUpdate, + SaveAsTreeRequest, + SaveAsTreeResponse, + SessionComplete, +) from app.api.deps import get_current_active_user from app.core.permissions import can_access_tree from app.services.export_service import generate_markdown_export, generate_text_export, generate_html_export, generate_psa_export @@ -198,6 +207,7 @@ async def update_session( @router.post("/{session_id}/complete", response_model=SessionResponse) async def complete_session( session_id: UUID, + completion_data: SessionComplete, db: Annotated[AsyncSession, Depends(get_db)], current_user: Annotated[User, Depends(get_current_active_user)] ): @@ -224,6 +234,8 @@ async def complete_session( ) session.completed_at = datetime.now(timezone.utc) + session.outcome = completion_data.outcome + session.outcome_notes = completion_data.outcome_notes await db.commit() await db.refresh(session) return session diff --git a/backend/app/models/session.py b/backend/app/models/session.py index 9131d2b1..a8984d8a 100644 --- a/backend/app/models/session.py +++ b/backend/app/models/session.py @@ -45,6 +45,8 @@ class Session(Base): nullable=True, index=True ) + outcome: Mapped[Optional[str]] = mapped_column(String(20), nullable=True, index=True) + outcome_notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True) ticket_number: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) client_name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) exported: Mapped[bool] = mapped_column(Boolean, default=False) diff --git a/backend/app/schemas/session.py b/backend/app/schemas/session.py index 37e394d3..45958b1b 100644 --- a/backend/app/schemas/session.py +++ b/backend/app/schemas/session.py @@ -3,6 +3,8 @@ from typing import Optional, Any, Literal from uuid import UUID from pydantic import BaseModel, Field, validator +SessionOutcome = Literal["resolved", "escalated", "workaround", "unresolved"] + class CustomStepSchema(BaseModel): """Enhanced custom step with source tracking. @@ -28,6 +30,9 @@ class DecisionRecord(BaseModel): notes: Optional[str] = None automation_used: Optional[bool] = False timestamp: datetime + entered_at: Optional[datetime] = None + exited_at: Optional[datetime] = None + duration_seconds: Optional[int] = Field(None, ge=0) attachments: list[str] = Field(default_factory=list) @@ -57,6 +62,8 @@ class SessionResponse(BaseModel): custom_steps: list[dict[str, Any]] = Field(default_factory=list) started_at: datetime completed_at: Optional[datetime] = None + outcome: Optional[SessionOutcome] = None + outcome_notes: Optional[str] = None ticket_number: Optional[str] = None client_name: Optional[str] = None exported: bool @@ -77,6 +84,11 @@ class SessionExport(BaseModel): include_tree_info: bool = True +class SessionComplete(BaseModel): + outcome: SessionOutcome + outcome_notes: Optional[str] = None + + class ScratchpadUpdate(BaseModel): scratchpad: str diff --git a/backend/app/services/export_service.py b/backend/app/services/export_service.py index 0fb84acc..6c826e88 100644 --- a/backend/app/services/export_service.py +++ b/backend/app/services/export_service.py @@ -5,12 +5,21 @@ Provides markdown, plain text, HTML, and PSA/ticket note export formatters for troubleshooting sessions. """ import html -from datetime import datetime, timezone +from datetime import datetime +from typing import Any from app.models.session import Session from app.schemas.session import SessionExport +OUTCOME_LABELS = { + "resolved": "Resolved", + "escalated": "Escalated", + "workaround": "Workaround Applied", + "unresolved": "Unresolved", +} + + def _format_duration(started_at: datetime, completed_at: datetime | None) -> str: """Format duration between two datetimes as human-readable string.""" if not completed_at: @@ -26,9 +35,64 @@ def _format_duration(started_at: datetime, completed_at: datetime | None) -> str return f"{minutes} minutes" +def _format_step_duration(duration_seconds: int) -> str: + """Format step duration seconds as compact human-readable text.""" + if duration_seconds < 0: + return "0s" + if duration_seconds < 60: + return f"{duration_seconds}s" + hours, remainder = divmod(duration_seconds, 3600) + minutes, seconds = divmod(remainder, 60) + if hours > 0: + if seconds > 0: + return f"{hours}h {minutes}m {seconds}s" + return f"{hours}h {minutes}m" + if seconds > 0: + return f"{minutes}m {seconds}s" + return f"{minutes}m" + + +def _parse_iso_datetime(value: Any) -> datetime | None: + """Parse ISO datetime strings from JSONB with support for trailing Z.""" + if isinstance(value, datetime): + return value + if not isinstance(value, str) or not value: + return None + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + + +def _get_step_duration_seconds(decision: dict[str, Any]) -> int | None: + """Get step duration from explicit field or entered/exited timestamps.""" + explicit_duration = decision.get("duration_seconds") + if isinstance(explicit_duration, (int, float)): + duration = int(explicit_duration) + if duration >= 0: + return duration + + entered_at = _parse_iso_datetime(decision.get("entered_at")) + exited_at = _parse_iso_datetime(decision.get("exited_at")) + if entered_at and exited_at: + total_seconds = int((exited_at - entered_at).total_seconds()) + if total_seconds >= 0: + return total_seconds + return None + + +def _get_outcome_label(session: Session) -> str | None: + """Map stored outcome enum to human-friendly label.""" + outcome = getattr(session, "outcome", None) + if not outcome: + return None + return OUTCOME_LABELS.get(outcome, str(outcome).replace("_", " ").title()) + + def generate_markdown_export(session: Session, options: SessionExport) -> str: """Generate markdown export.""" lines = [] + outcome_label = _get_outcome_label(session) if options.include_tree_info: tree_name = session.tree_snapshot.get("name", "Troubleshooting Session") @@ -42,6 +106,9 @@ def generate_markdown_export(session: Session, options: SessionExport) -> str: 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("") @@ -63,12 +130,15 @@ def generate_markdown_export(session: Session, options: SessionExport) -> str: question = decision.get("question") or decision.get("action_performed", "Step") answer = decision.get("answer", "") notes = decision.get("notes", "") + duration_seconds = _get_step_duration_seconds(decision) lines.append(f"### Step {i}: {question}") if answer: lines.append(f"**Answer:** {answer}") if notes: lines.append(f"**Notes:** {notes}") + if duration_seconds is not None: + lines.append(f"**Duration:** {_format_step_duration(duration_seconds)}") if options.include_timestamps and decision.get("timestamp"): lines.append(f"*{decision['timestamp']}*") lines.append("") @@ -79,6 +149,7 @@ def generate_markdown_export(session: Session, options: SessionExport) -> str: def generate_text_export(session: Session, options: SessionExport) -> str: """Generate plain text export.""" lines = [] + outcome_label = _get_outcome_label(session) if options.include_tree_info: tree_name = session.tree_snapshot.get("name", "Troubleshooting Session") @@ -92,6 +163,9 @@ def generate_text_export(session: Session, options: SessionExport) -> str: 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("") # Scratchpad / Evidence section @@ -109,12 +183,15 @@ def generate_text_export(session: Session, options: SessionExport) -> str: question = decision.get("question") or decision.get("action_performed", "Step") answer = decision.get("answer", "") notes = decision.get("notes", "") + duration_seconds = _get_step_duration_seconds(decision) lines.append(f"\n{i}. {question}") if answer: lines.append(f" Answer: {answer}") if notes: lines.append(f" Notes: {notes}") + if duration_seconds is not None: + lines.append(f" Duration: {_format_step_duration(duration_seconds)}") return "\n".join(lines) @@ -122,6 +199,7 @@ def generate_text_export(session: Session, options: SessionExport) -> str: def generate_html_export(session: Session, options: SessionExport) -> str: """Generate HTML export.""" tree_name = html.escape(session.tree_snapshot.get("name", "Troubleshooting Session")) + outcome_label = _get_outcome_label(session) html_parts = ['', '', '', '', @@ -134,6 +212,7 @@ def generate_html_export(session: Session, options: SessionExport) -> str: '.step h3 { margin: 0 0 10px 0; color: #444; }', '.answer { font-weight: bold; }', '.notes { font-style: italic; color: #555; }', + '.duration { color: #444; margin-top: 6px; }', '.timestamp { font-size: 0.85em; color: #888; }', '', '', ''] @@ -149,6 +228,9 @@ def generate_html_export(session: Session, options: SessionExport) -> str: 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('') # Scratchpad / Evidence section @@ -163,6 +245,7 @@ def generate_html_export(session: Session, options: SessionExport) -> str: question = html.escape(decision.get("question") or decision.get("action_performed", "Step")) answer = html.escape(decision.get("answer", "")) notes = html.escape(decision.get("notes", "")) + duration_seconds = _get_step_duration_seconds(decision) html_parts.append('
') html_parts.append(f'

Step {i}: {question}

') @@ -170,6 +253,8 @@ def generate_html_export(session: Session, options: SessionExport) -> str: html_parts.append(f'

Answer: {answer}

') if notes: html_parts.append(f'

Notes: {notes}

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

Duration: {_format_step_duration(duration_seconds)}

') if options.include_timestamps and decision.get("timestamp"): html_parts.append(f'

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

') html_parts.append('
') @@ -181,6 +266,7 @@ 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.""" lines = [] + outcome_label = _get_outcome_label(session) tree_name = session.tree_snapshot.get("name", "Troubleshooting Session") tree_description = session.tree_snapshot.get("description", "") @@ -191,6 +277,9 @@ def generate_psa_export(session: Session, options: SessionExport) -> str: lines.append("=== TROUBLESHOOTING NOTES ===") lines.append(f"Ticket: {ticket_number} | Client: {client_name}") lines.append(f"Tree: {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("") # Problem section @@ -205,10 +294,13 @@ def generate_psa_export(session: Session, options: SessionExport) -> str: question = decision.get("question") or decision.get("action_performed", "Step") answer = decision.get("answer", "") notes = decision.get("notes", "") + duration_seconds = _get_step_duration_seconds(decision) line = f"{i}. {question}" if answer: line += f" -> {answer}" + if duration_seconds is not None: + line += f" ({_format_step_duration(duration_seconds)})" lines.append(line) if notes: lines.append(f" Notes: {notes}") @@ -224,6 +316,8 @@ def generate_psa_export(session: Session, options: SessionExport) -> str: lines.append(resolution) else: lines.append("No resolution recorded.") + if outcome_label: + lines.append(f"Outcome: {outcome_label}") lines.append("") # Time spent diff --git a/backend/tests/test_sessions.py b/backend/tests/test_sessions.py index f6a3b687..f2dfa351 100644 --- a/backend/tests/test_sessions.py +++ b/backend/tests/test_sessions.py @@ -153,12 +153,34 @@ class TestSessions: # Complete session response = await client.post( f"/api/v1/sessions/{session_id}/complete", + json={"outcome": "resolved", "outcome_notes": "Issue fixed after restarting service"}, headers=auth_headers ) assert response.status_code == 200 data = response.json() assert data["completed_at"] is not None + assert data["outcome"] == "resolved" + assert data["outcome_notes"] == "Issue fixed after restarting service" + + @pytest.mark.asyncio + async def test_complete_session_requires_outcome( + self, client: AsyncClient, auth_headers: dict, test_tree: dict + ): + """Test that completion requires an outcome payload.""" + create_response = await client.post( + "/api/v1/sessions", + json={"tree_id": test_tree["id"]}, + headers=auth_headers + ) + session_id = create_response.json()["id"] + + response = await client.post( + f"/api/v1/sessions/{session_id}/complete", + headers=auth_headers + ) + + assert response.status_code == 422 @pytest.mark.asyncio async def test_complete_already_completed_session( @@ -175,12 +197,14 @@ class TestSessions: await client.post( f"/api/v1/sessions/{session_id}/complete", + json={"outcome": "resolved"}, headers=auth_headers ) # Try to complete again response = await client.post( f"/api/v1/sessions/{session_id}/complete", + json={"outcome": "resolved"}, headers=auth_headers ) @@ -217,6 +241,59 @@ class TestSessions: assert "EXP-001" in content # Should contain ticket number assert "#" in content # Markdown headers + @pytest.mark.asyncio + async def test_export_markdown_includes_outcome_and_step_duration( + self, client: AsyncClient, auth_headers: dict, test_tree: dict + ): + """Test markdown export includes session outcome and per-step duration.""" + create_response = await client.post( + "/api/v1/sessions", + json={"tree_id": test_tree["id"], "ticket_number": "EXP-OUTCOME-001"}, + headers=auth_headers + ) + session_id = create_response.json()["id"] + + step_timestamp = "2026-02-11T10:10:00Z" + update_response = await client.put( + f"/api/v1/sessions/{session_id}", + json={ + "decisions": [{ + "node_id": "root", + "question": "Is this a test?", + "answer": "Yes", + "action_performed": None, + "notes": "Validated quickly", + "automation_used": False, + "timestamp": step_timestamp, + "entered_at": "2026-02-11T10:08:30Z", + "exited_at": step_timestamp, + "duration_seconds": 90, + "attachments": [] + }] + }, + headers=auth_headers + ) + assert update_response.status_code == 200 + + complete_response = await client.post( + f"/api/v1/sessions/{session_id}/complete", + json={"outcome": "workaround", "outcome_notes": "Temporary mitigation applied"}, + headers=auth_headers + ) + assert complete_response.status_code == 200 + + export_response = await client.post( + f"/api/v1/sessions/{session_id}/export", + json={"format": "markdown", "include_timestamps": True, "include_tree_info": True}, + headers=auth_headers + ) + + assert export_response.status_code == 200 + content = export_response.text + assert "**Outcome:** Workaround Applied" in content + assert "**Duration:**" in content + assert "**Duration:** 1m 30s" in content + @pytest.mark.asyncio async def test_export_session_text( self, client: AsyncClient, auth_headers: dict, test_tree: dict @@ -291,6 +368,7 @@ class TestSessions: # Complete first session await client.post( f"/api/v1/sessions/{session1_id}/complete", + json={"outcome": "resolved"}, headers=auth_headers ) @@ -449,6 +527,7 @@ class TestSessions: await client.post( f"/api/v1/sessions/{session_id}/complete", + json={"outcome": "resolved"}, headers=auth_headers ) @@ -857,6 +936,7 @@ class TestSessions: await client.post( f"/api/v1/sessions/{session_id}/complete", + json={"outcome": "resolved"}, headers=auth_headers ) diff --git a/frontend/src/api/sessions.ts b/frontend/src/api/sessions.ts index 0c10296e..2c62f66c 100644 --- a/frontend/src/api/sessions.ts +++ b/frontend/src/api/sessions.ts @@ -1,5 +1,5 @@ import apiClient from './client' -import type { Session, SessionCreate, SessionUpdate, SessionExport, SaveAsTreeRequest, SaveAsTreeResponse } from '@/types' +import type { Session, SessionCreate, SessionUpdate, SessionExport, SaveAsTreeRequest, SaveAsTreeResponse, SessionComplete } from '@/types' export interface SessionListParams { page?: number @@ -44,8 +44,8 @@ export const sessionsApi = { return response.data }, - async complete(id: string): Promise { - const response = await apiClient.post(`/sessions/${id}/complete`) + async complete(id: string, data: SessionComplete): Promise { + const response = await apiClient.post(`/sessions/${id}/complete`, data) return response.data }, diff --git a/frontend/src/components/session/SessionOutcomeModal.tsx b/frontend/src/components/session/SessionOutcomeModal.tsx new file mode 100644 index 00000000..423baa67 --- /dev/null +++ b/frontend/src/components/session/SessionOutcomeModal.tsx @@ -0,0 +1,122 @@ +import { useEffect, useState } from 'react' +import { Modal } from '@/components/common/Modal' +import { cn } from '@/lib/utils' +import type { SessionOutcome } from '@/types' + +interface SessionOutcomeModalProps { + isOpen: boolean + onClose: () => void + onSubmit: (data: { outcome: SessionOutcome; outcome_notes?: string }) => Promise + isSubmitting?: boolean +} + +const OUTCOME_OPTIONS: Array<{ value: SessionOutcome; label: string; description: string }> = [ + { value: 'resolved', label: 'Resolved', description: 'Issue fully resolved in this session.' }, + { value: 'workaround', label: 'Workaround', description: 'Temporary fix applied, root cause remains.' }, + { value: 'escalated', label: 'Escalated', description: 'Handed off to another engineer/team.' }, + { value: 'unresolved', label: 'Unresolved', description: 'No fix or workaround identified yet.' }, +] + +export function SessionOutcomeModal({ + isOpen, + onClose, + onSubmit, + isSubmitting = false, +}: SessionOutcomeModalProps) { + const [outcome, setOutcome] = useState('resolved') + const [outcomeNotes, setOutcomeNotes] = useState('') + + useEffect(() => { + if (!isOpen) return + setOutcome('resolved') + setOutcomeNotes('') + }, [isOpen]) + + const handleSubmit = async () => { + await onSubmit({ + outcome, + outcome_notes: outcomeNotes.trim() || undefined, + }) + } + + return ( + + + + + )} + > +
+

+ Select the session outcome before completion. +

+
+ {OUTCOME_OPTIONS.map((option) => ( + + ))} +
+ +
+ +