Add step library foundation and user preferences (#24)

## Summary
Implements Phase 2.5 Step Library Foundation:

### Issues Completed
- #3 User Preferences - export format default setting
- #5 Step Categories - database table and seed data  
- #6 Step Library - database schema and migrations
- #7 Step Library - CRUD API endpoints
- #8 Step Library - rating and review system

### Changes
**Backend:**
- Migration 007: step_categories table with 10 seeded global categories
- Migration 008: step_library, step_ratings, step_usage_log tables
- Full CRUD API for step categories (/api/v1/step-categories)
- Full CRUD API for step library (/api/v1/steps) with search, filters, ratings
- CORS support for Railway PR environments (ALLOW_RAILWAY_ORIGINS)

**Frontend:**
- User preferences store (Zustand + localStorage)
- Settings page at /settings with export format dropdown
- Default export format applied in SessionDetailPage

### Testing
- Tested in Railway PR environment
- Database seeded with 7 MSP troubleshooting trees
- All API endpoints verified working
This commit was merged in pull request #24.
This commit is contained in:
chihlasm
2026-02-03 02:07:46 -05:00
committed by GitHub
parent 1e4eec00e2
commit 7803dc4522
20 changed files with 1797 additions and 25 deletions

View File

@@ -1,7 +1,7 @@
# CLAUDE.md - Patherly Project Context
> **Purpose:** This file provides Claude Code with essential context for working on the Patherly project.
> **Last Updated:** February 2, 2026
> **Last Updated:** February 3, 2026
---
@@ -19,9 +19,9 @@
## Current State
- **Phase:** Phase 2 - Tree Editor (In Progress)
- **Backend:** Complete (18 API endpoints, 40+ integration tests, all passing)
- **Frontend:** Core features complete, Tree Editor functional
- **Phase:** Phase 2.5 - Step Library Foundation (In Progress)
- **Backend:** Complete (20+ API endpoints, 40+ integration tests, all passing)
- **Frontend:** Core features complete, Tree Editor functional, Settings page added
- **Database:** PostgreSQL with Docker (container name: `patherly_postgres`)
### What's Complete
@@ -42,9 +42,28 @@
- Cascade delete for subfolders
- Team admin role with scoped permissions
- Filter trees by category, tags, and folders
- **User Preferences (Issue #3):**
- Settings page at `/settings`
- Default export format preference (persisted in localStorage)
- Theme toggle integrated in Settings
- **Step Categories (Issue #5):**
- Database table with 10 seeded global categories
- Full CRUD API at `/api/v1/step-categories`
- Team scoping support (global + team-specific)
- **Step Library Schema (Issue #6):**
- `step_library` table for reusable troubleshooting steps
- `step_ratings` table for user ratings/reviews
- `step_usage_log` table for tracking verified use
- Support for decision/action/solution step types
- Visibility levels: private, team, public
- **Step Library API (Issue #7):**
- Full CRUD at `/api/v1/steps`
- Full-text search endpoint
- Popular tags endpoint
- Rating/review system with verified use tracking
### What's In Progress
- User preferences (export format default)
- Step Library frontend UI (Phase 2.5 continuation)
### Deployment
- **Production:** Railway (app.patherly.com / api.patherly.com)
@@ -99,9 +118,11 @@ patherly/
│ │ │ ├── session.py # JSONB path_taken, decisions
│ │ │ ├── attachment.py
│ │ │ ├── invite_code.py
│ │ │ ├── category.py # TreeCategory model (NEW)
│ │ │ ├── tag.py # TreeTag model (NEW)
│ │ │ ── folder.py # UserFolder model (NEW)
│ │ │ ├── category.py # TreeCategory model
│ │ │ ├── tag.py # TreeTag model
│ │ │ ── folder.py # UserFolder model
│ │ │ ├── step_category.py # StepCategory model (NEW)
│ │ │ └── step_library.py # StepLibrary, StepRating, StepUsageLog (NEW)
│ │ └── schemas/ # Pydantic schemas
│ ├── alembic/ # Database migrations
│ ├── scripts/
@@ -125,7 +146,8 @@ patherly/
│ │ ├── store/
│ │ │ ├── authStore.ts # Zustand auth state
│ │ │ ├── themeStore.ts # Dark/light theme
│ │ │ ── treeEditorStore.ts # Tree editor state (immer + zundo)
│ │ │ ── treeEditorStore.ts # Tree editor state (immer + zundo)
│ │ │ └── userPreferencesStore.ts # User preferences (NEW)
│ │ ├── components/
│ │ │ ├── common/ # Modal, ErrorBoundary, ThemeToggle
│ │ │ ├── layout/ # AppLayout, ProtectedRoute
@@ -139,7 +161,8 @@ patherly/
│ │ │ ├── TreeNavigationPage.tsx # Core feature
│ │ │ ├── TreeEditorPage.tsx
│ │ │ ├── SessionHistoryPage.tsx
│ │ │ ── SessionDetailPage.tsx
│ │ │ ── SessionDetailPage.tsx
│ │ │ └── SettingsPage.tsx # User preferences (NEW)
│ │ ├── types/ # TypeScript interfaces
│ │ └── lib/utils.ts # cn() utility for Tailwind
│ ├── package.json
@@ -352,7 +375,7 @@ PUT /api/v1/tags/trees/{id} - Replace tree's tags
DELETE /api/v1/tags/trees/{id}/{slug} - Remove tag from tree
```
### Folders (NEW)
### Folders
```
GET /api/v1/folders - List user's folders (includes parent_id)
POST /api/v1/folders - Create folder (supports parent_id for subfolders)
@@ -370,6 +393,32 @@ DELETE /api/v1/folders/{id}/trees/{tree_id} - Remove tree from folder
- Same folder name allowed under different parents
- Moving folders validates cycle prevention
### Step Categories (NEW)
```
GET /api/v1/step-categories - List categories (global + user's team)
POST /api/v1/step-categories - Create category (admin/team_admin)
GET /api/v1/step-categories/{id} - Get category
PUT /api/v1/step-categories/{id} - Update category
DELETE /api/v1/step-categories/{id} - Soft delete category
```
### Step Library (NEW)
```
GET /api/v1/steps - List steps (filters: visibility, category_id, tags, min_rating, step_type, sort_by)
POST /api/v1/steps - Create step
GET /api/v1/steps/{id} - Get step details
PUT /api/v1/steps/{id} - Update step (owner/admin)
DELETE /api/v1/steps/{id} - Soft delete step (owner/admin)
GET /api/v1/steps/search - Full-text search (?q=query)
GET /api/v1/steps/tags/popular - Popular tags list
# Rating endpoints
POST /api/v1/steps/{id}/rate - Rate a step (1-5 stars + optional review)
PUT /api/v1/steps/{id}/rate - Update your rating
DELETE /api/v1/steps/{id}/rate - Remove your rating
GET /api/v1/steps/{id}/reviews - Get reviews for a step
```
### Sessions
```
GET /api/v1/sessions - List user's sessions
@@ -441,6 +490,7 @@ interface Decision {
- **Auth:** `useAuthStore` - Zustand with localStorage persistence
- **Theme:** `useThemeStore` - Dark/light/system preference
- **Tree Editor:** `useTreeEditorStore` - Zustand + immer + zundo (undo/redo)
- **User Preferences:** `useUserPreferencesStore` - Zustand with localStorage persistence (export format default)
### Component Guidelines
- Use `cn()` from `@/lib/utils` for Tailwind class merging
@@ -508,10 +558,16 @@ const response = await api.get('/api/v1/trees')
## Future Roadmap
### Phase 2.5 (Planned)
- Personal tree branching (add custom steps during sessions)
- Step library with categories, tags, and ratings
- Tree forking and sharing
### Phase 2.5 (In Progress)
- ✅ Step Categories database and API
- Step Library database schema (step_library, step_ratings, step_usage_log)
- ✅ Step Library CRUD API with search and ratings
- ✅ User Preferences (Settings page, export format default)
- 🔲 Step Library browser component (frontend)
- 🔲 Add Custom Step button in tree navigation
- 🔲 Custom step creation modal
- 🔲 Personal tree branching (add custom steps during sessions)
- 🔲 Tree forking and sharing
### Phase 3 (Planned)
- File attachments (screenshots, logs)
@@ -580,6 +636,7 @@ Railway creates isolated preview environments for each pull request.
- Switch to the PR environment
- Click on each service → Settings → Networking → Generate Domain
6. **Set `VITE_API_URL`** on frontend service to point to the PR backend URL
- **IMPORTANT:** Must include `https://` prefix (e.g., `https://patherly-patherly-pr-24.up.railway.app`)
7. Redeploy frontend if needed
8. Test at preview URLs
9. Merge PR → auto-deploys to production
@@ -589,12 +646,16 @@ Railway creates isolated preview environments for each pull request.
- PR environments inherit from `production` base environment
- `REQUIRE_INVITE_CODE=true` is inherited (create invite codes in PR DB if needed)
- `DATABASE_URL` is auto-provided for isolated PR database
- `ALLOW_RAILWAY_ORIGINS=true` (shared variable) - enables CORS for all `*.up.railway.app` origins
**Notes:**
- Each PR gets a fresh database - no existing users/trees
- Migrations run automatically via `releaseCommand`
- Domains must be generated manually for each PR service
**Debug Endpoints (available in PR environments):**
- `/debug/cors` - Check CORS configuration (allow_railway_origins, cors_mode)
---
## Contact

View File

@@ -0,0 +1,67 @@
"""Add step_categories table for step library organization
Revision ID: 007
Revises: 006
Create Date: 2026-02-03
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = '007'
down_revision: Union[str, None] = '006'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ===========================================
# Create step_categories table
# ===========================================
op.create_table(
'step_categories',
sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text('gen_random_uuid()')),
sa.Column('name', sa.String(100), nullable=False),
sa.Column('slug', sa.String(100), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
# Team scoping (NULL = global, like TreeCategory)
sa.Column('team_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('teams.id', ondelete='CASCADE'), nullable=True),
sa.Column('display_order', sa.Integer(), nullable=False, server_default='0'),
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'),
sa.Column('created_by', postgresql.UUID(as_uuid=True), sa.ForeignKey('users.id', ondelete='SET NULL'), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()')),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()')),
sa.UniqueConstraint('slug', 'team_id', name='uq_step_categories_slug_team')
)
op.create_index('ix_step_categories_team_id', 'step_categories', ['team_id'])
op.create_index('ix_step_categories_slug', 'step_categories', ['slug'])
op.create_index('ix_step_categories_display_order', 'step_categories', ['display_order'])
# ===========================================
# Seed default GLOBAL categories (team_id = NULL)
# ===========================================
op.execute("""
INSERT INTO step_categories (name, slug, description, display_order, team_id) VALUES
('Citrix / VDI', 'citrix-vdi', 'Virtual desktop, XenApp, XenDesktop, VDA issues', 1, NULL),
('Active Directory', 'active-directory', 'AD, LDAP, Group Policy, authentication', 2, NULL),
('Microsoft 365', 'microsoft-365', 'Exchange Online, Teams, SharePoint, OneDrive', 3, NULL),
('Networking', 'networking', 'DNS, DHCP, VPN, firewall, connectivity', 4, NULL),
('File Services', 'file-services', 'File shares, permissions, DFS, storage', 5, NULL),
('Printing', 'printing', 'Print servers, drivers, spooler issues', 6, NULL),
('Backup & Recovery', 'backup-recovery', 'Backup software, disaster recovery, restore', 7, NULL),
('Security', 'security', 'Antivirus, permissions, security incidents', 8, NULL),
('Hardware', 'hardware', 'Servers, workstations, peripherals', 9, NULL),
('Other', 'other', 'Miscellaneous steps', 100, NULL)
""")
def downgrade() -> None:
op.drop_index('ix_step_categories_display_order', table_name='step_categories')
op.drop_index('ix_step_categories_slug', table_name='step_categories')
op.drop_index('ix_step_categories_team_id', table_name='step_categories')
op.drop_table('step_categories')

View File

@@ -0,0 +1,97 @@
"""add step library tables
Revision ID: 008
Revises: 007
Create Date: 2026-02-03
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = '008'
down_revision: Union[str, None] = '007'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# step_library table
op.create_table('step_library',
sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text('gen_random_uuid()')),
sa.Column('title', sa.String(255), nullable=False),
sa.Column('step_type', sa.String(50), nullable=False),
sa.Column('content', postgresql.JSONB, nullable=False),
# Ownership
sa.Column('created_by', postgresql.UUID(as_uuid=True), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False),
sa.Column('team_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('teams.id', ondelete='CASCADE'), nullable=True),
# Organization
sa.Column('category_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('step_categories.id', ondelete='SET NULL'), nullable=True),
sa.Column('tags', postgresql.ARRAY(sa.String(100)), nullable=False, server_default='{}'),
# Visibility: 'private', 'team', 'public'
sa.Column('visibility', sa.String(50), nullable=False, server_default=sa.text("'private'")),
# Aggregated ratings (updated by application)
sa.Column('usage_count', sa.Integer(), nullable=False, server_default='0'),
sa.Column('rating_average', sa.Numeric(3, 2), nullable=False, server_default='0'),
sa.Column('rating_count', sa.Integer(), nullable=False, server_default='0'),
sa.Column('helpful_yes', sa.Integer(), nullable=False, server_default='0'),
sa.Column('helpful_no', sa.Integer(), nullable=False, server_default='0'),
# Flags
sa.Column('is_featured', sa.Boolean(), nullable=False, server_default='false'),
sa.Column('is_verified', sa.Boolean(), nullable=False, server_default='false'),
# Timestamps
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()')),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()')),
# Soft delete
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'),
# CHECK constraint for step_type
sa.CheckConstraint("step_type IN ('decision', 'action', 'solution')", name='ck_step_library_step_type')
)
# Indexes for step_library
op.create_index('ix_step_library_created_by', 'step_library', ['created_by'])
op.create_index('ix_step_library_team_id', 'step_library', ['team_id'])
op.create_index('ix_step_library_category_id', 'step_library', ['category_id'])
op.create_index('ix_step_library_visibility', 'step_library', ['visibility'], postgresql_where=sa.text('is_active = true'))
op.create_index('ix_step_library_tags', 'step_library', ['tags'], postgresql_using='gin')
op.create_index('ix_step_library_search', 'step_library', [sa.text("to_tsvector('english', title)")], postgresql_using='gin')
# step_ratings table
op.create_table('step_ratings',
sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text('gen_random_uuid()')),
sa.Column('step_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('step_library.id', ondelete='CASCADE'), nullable=False),
sa.Column('user_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False),
sa.Column('rating', sa.Integer(), nullable=False),
sa.Column('was_helpful', sa.Boolean(), nullable=True),
sa.Column('review_text', sa.String(500), nullable=True),
sa.Column('is_verified_use', sa.Boolean(), nullable=False, server_default='false'),
sa.Column('session_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('sessions.id', ondelete='SET NULL'), nullable=True),
sa.Column('is_visible', sa.Boolean(), nullable=False, server_default='true'),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()')),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()')),
sa.UniqueConstraint('step_id', 'user_id', name='uq_step_ratings_step_user'),
sa.CheckConstraint('rating >= 1 AND rating <= 5', name='ck_step_ratings_rating_range')
)
op.create_index('ix_step_ratings_step_id', 'step_ratings', ['step_id'])
op.create_index('ix_step_ratings_user_id', 'step_ratings', ['user_id'])
# step_usage_log table
op.create_table('step_usage_log',
sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text('gen_random_uuid()')),
sa.Column('step_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('step_library.id', ondelete='CASCADE'), nullable=False),
sa.Column('user_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False),
sa.Column('session_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('sessions.id', ondelete='CASCADE'), nullable=False),
sa.Column('used_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()'))
)
op.create_index('ix_step_usage_log_step_id', 'step_usage_log', ['step_id'])
op.create_index('ix_step_usage_log_user_step', 'step_usage_log', ['user_id', 'step_id'])
def downgrade() -> None:
op.drop_table('step_usage_log')
op.drop_table('step_ratings')
op.drop_table('step_library')

View File

@@ -0,0 +1,288 @@
from typing import Annotated, Optional
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, or_
from app.core.database import get_db
from app.models.step_category import StepCategory
from app.models.user import User
from app.schemas.step_category import (
StepCategoryCreate,
StepCategoryUpdate,
StepCategoryResponse,
StepCategoryListResponse,
slugify
)
from app.api.deps import get_current_user
router = APIRouter(prefix="/step-categories", tags=["step-categories"])
def can_manage_step_category(user: User, category: StepCategory) -> bool:
"""Check if user can manage (edit/delete) a step category."""
# Global admins can manage any category
if user.role == "admin":
return True
# Team admins can manage their team's categories
if user.is_team_admin and category.team_id == user.team_id:
return True
return False
def can_create_step_category(user: User, team_id: Optional[UUID]) -> bool:
"""Check if user can create a step category for the given team."""
# Global admins can create global categories (team_id=None) or any team's categories
if user.role == "admin":
return True
# Team admins can only create categories for their own team
if user.is_team_admin and team_id == user.team_id:
return True
return False
@router.get("", response_model=list[StepCategoryListResponse])
async def list_step_categories(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)],
include_inactive: bool = Query(False, description="Include inactive categories"),
team_only: bool = Query(False, description="Only show team-specific categories")
):
"""List step categories visible to the user.
Returns global categories plus team-specific categories for the user's team.
"""
# Build query for accessible categories
query = select(StepCategory)
# Filter by active status
if not include_inactive:
query = query.where(StepCategory.is_active == True)
# Filter by visibility: global OR user's team
if team_only and current_user.team_id:
query = query.where(StepCategory.team_id == current_user.team_id)
elif current_user.team_id:
query = query.where(
or_(
StepCategory.team_id.is_(None), # Global
StepCategory.team_id == current_user.team_id # User's team
)
)
else:
# User has no team, only show global categories
query = query.where(StepCategory.team_id.is_(None))
query = query.order_by(StepCategory.display_order, StepCategory.name)
result = await db.execute(query)
categories = result.scalars().all()
# For now, step_count will be 0 since step_library table doesn't exist yet
# This will be updated in Issue #7 when we have the step_library table
response = []
for cat in categories:
response.append(StepCategoryListResponse(
id=cat.id,
name=cat.name,
slug=cat.slug,
description=cat.description,
team_id=cat.team_id,
display_order=cat.display_order,
is_active=cat.is_active,
step_count=0 # Will be computed when step_library exists
))
return response
@router.get("/{category_id}", response_model=StepCategoryResponse)
async def get_step_category(
category_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)]
):
"""Get a specific step category by ID."""
result = await db.execute(select(StepCategory).where(StepCategory.id == category_id))
category = result.scalar_one_or_none()
if not category:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Step category not found"
)
# Check access: global categories visible to all, team categories only to team members
if category.team_id and category.team_id != current_user.team_id and current_user.role != "admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have access to this step category"
)
return StepCategoryResponse(
id=category.id,
name=category.name,
slug=category.slug,
description=category.description,
team_id=category.team_id,
display_order=category.display_order,
is_active=category.is_active,
created_at=category.created_at,
updated_at=category.updated_at,
step_count=0 # Will be computed when step_library exists
)
@router.post("", response_model=StepCategoryResponse, status_code=status.HTTP_201_CREATED)
async def create_step_category(
category_data: StepCategoryCreate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)]
):
"""Create a new step category.
- Global admins can create global categories (team_id=None)
- Team admins can create team-specific categories for their team
"""
if not can_create_step_category(current_user, category_data.team_id):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have permission to create this step category"
)
# Generate slug
slug = slugify(category_data.name)
# Check for duplicate slug within same scope (global or team)
existing_query = select(StepCategory).where(
StepCategory.slug == slug,
StepCategory.team_id == category_data.team_id
)
existing = await db.execute(existing_query)
if existing.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"A step category with slug '{slug}' already exists"
)
# Get next display order
order_query = select(func.max(StepCategory.display_order)).where(
StepCategory.team_id == category_data.team_id
)
order_result = await db.execute(order_query)
max_order = order_result.scalar() or 0
new_category = StepCategory(
name=category_data.name,
slug=slug,
description=category_data.description,
team_id=category_data.team_id,
display_order=max_order + 1,
created_by=current_user.id
)
db.add(new_category)
await db.commit()
await db.refresh(new_category)
return StepCategoryResponse(
id=new_category.id,
name=new_category.name,
slug=new_category.slug,
description=new_category.description,
team_id=new_category.team_id,
display_order=new_category.display_order,
is_active=new_category.is_active,
created_at=new_category.created_at,
updated_at=new_category.updated_at,
step_count=0
)
@router.put("/{category_id}", response_model=StepCategoryResponse)
async def update_step_category(
category_id: UUID,
category_data: StepCategoryUpdate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)]
):
"""Update a step category."""
result = await db.execute(select(StepCategory).where(StepCategory.id == category_id))
category = result.scalar_one_or_none()
if not category:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Step category not found"
)
if not can_manage_step_category(current_user, category):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have permission to update this step category"
)
# Update fields
update_data = category_data.model_dump(exclude_unset=True)
# If name is being updated, regenerate slug
if "name" in update_data:
new_slug = slugify(update_data["name"])
# Check for duplicate slug
existing_query = select(StepCategory).where(
StepCategory.slug == new_slug,
StepCategory.team_id == category.team_id,
StepCategory.id != category_id
)
existing = await db.execute(existing_query)
if existing.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"A step category with slug '{new_slug}' already exists"
)
category.slug = new_slug
for field, value in update_data.items():
setattr(category, field, value)
await db.commit()
await db.refresh(category)
return StepCategoryResponse(
id=category.id,
name=category.name,
slug=category.slug,
description=category.description,
team_id=category.team_id,
display_order=category.display_order,
is_active=category.is_active,
created_at=category.created_at,
updated_at=category.updated_at,
step_count=0 # Will be computed when step_library exists
)
@router.delete("/{category_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_step_category(
category_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)]
):
"""Soft delete (archive) a step category."""
result = await db.execute(select(StepCategory).where(StepCategory.id == category_id))
category = result.scalar_one_or_none()
if not category:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Step category not found"
)
if not can_manage_step_category(current_user, category):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have permission to delete this step category"
)
category.is_active = False
await db.commit()
return None

View File

@@ -0,0 +1,649 @@
from uuid import UUID
from typing import Optional
from datetime import datetime, timezone
from decimal import Decimal
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select, or_, and_, func, desc, Integer, case
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.api.deps import get_current_user
from app.models.user import User
from app.models.step_library import StepLibrary, StepRating
from app.models.step_category import StepCategory
from app.schemas.step_library import (
StepLibraryCreate,
StepLibraryUpdate,
StepLibraryResponse,
StepLibraryListResponse,
StepRatingCreate,
StepRatingUpdate,
StepRatingResponse,
PopularTagResponse,
)
router = APIRouter(prefix="/steps", tags=["steps"])
# Permission helpers
def can_view_step(user: User, step: StepLibrary) -> bool:
"""Check if user can view a step based on visibility."""
if step.visibility == 'public':
return True
if step.visibility == 'private':
return step.created_by == user.id
if step.visibility == 'team':
return step.team_id == user.team_id or user.role == 'admin'
return False
def can_edit_step(user: User, step: StepLibrary) -> bool:
"""Check if user can edit/delete a step."""
if user.role == 'admin':
return True
return step.created_by == user.id
async def get_step_or_404(
step_id: UUID,
db: AsyncSession,
current_user: User,
check_view: bool = True,
check_edit: bool = False
) -> StepLibrary:
"""Get step by ID with permission checks."""
result = await db.execute(
select(StepLibrary).where(
StepLibrary.id == step_id,
StepLibrary.is_active == True
)
)
step = result.scalar_one_or_none()
if not step:
raise HTTPException(status_code=404, detail="Step not found")
if check_view and not can_view_step(current_user, step):
raise HTTPException(status_code=403, detail="Not authorized to view this step")
if check_edit and not can_edit_step(current_user, step):
raise HTTPException(status_code=403, detail="Not authorized to modify this step")
return step
def build_visibility_filter(user: User):
"""Build SQLAlchemy filter for step visibility based on user."""
if user.team_id:
return or_(
StepLibrary.visibility == 'public',
and_(StepLibrary.visibility == 'team', StepLibrary.team_id == user.team_id),
StepLibrary.created_by == user.id # Own private steps
)
else:
return or_(
StepLibrary.visibility == 'public',
StepLibrary.created_by == user.id
)
@router.get("", response_model=list[StepLibraryListResponse])
async def list_steps(
visibility: Optional[str] = Query(None, regex="^(private|team|public)$"),
category_id: Optional[UUID] = None,
tags: Optional[list[str]] = Query(None),
min_rating: Optional[float] = Query(None, ge=0, le=5),
step_type: Optional[str] = Query(None, regex="^(decision|action|solution)$"),
sort_by: str = Query("recent", regex="^(recent|popular|highest_rated|most_used)$"),
limit: int = Query(20, ge=1, le=100),
offset: int = Query(0, ge=0),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""List steps with filters and pagination."""
query = select(StepLibrary).where(
StepLibrary.is_active == True,
build_visibility_filter(current_user)
)
# Apply filters
if visibility:
query = query.where(StepLibrary.visibility == visibility)
if category_id:
query = query.where(StepLibrary.category_id == category_id)
if tags:
# Match any of the provided tags
query = query.where(StepLibrary.tags.overlap(tags))
if min_rating is not None:
query = query.where(StepLibrary.rating_average >= Decimal(str(min_rating)))
if step_type:
query = query.where(StepLibrary.step_type == step_type)
# Apply sorting
if sort_by == "recent":
query = query.order_by(desc(StepLibrary.created_at))
elif sort_by == "popular" or sort_by == "most_used":
query = query.order_by(desc(StepLibrary.usage_count))
elif sort_by == "highest_rated":
query = query.order_by(desc(StepLibrary.rating_average), desc(StepLibrary.rating_count))
# Apply pagination
query = query.offset(offset).limit(limit)
result = await db.execute(query)
steps = result.scalars().all()
# Fetch category names and author names
response = []
for step in steps:
step_dict = {
"id": step.id,
"title": step.title,
"step_type": step.step_type,
"visibility": step.visibility,
"category_id": step.category_id,
"tags": step.tags,
"usage_count": step.usage_count,
"rating_average": step.rating_average,
"rating_count": step.rating_count,
"is_featured": step.is_featured,
"created_by": step.created_by,
"created_at": step.created_at,
}
# Get category name if exists
if step.category_id:
cat_result = await db.execute(
select(StepCategory.name).where(StepCategory.id == step.category_id)
)
cat_name = cat_result.scalar_one_or_none()
step_dict["category_name"] = cat_name
# Get author name
author_result = await db.execute(
select(User.name).where(User.id == step.created_by)
)
author_name = author_result.scalar_one_or_none()
step_dict["author_name"] = author_name
response.append(StepLibraryListResponse(**step_dict))
return response
@router.get("/search", response_model=list[StepLibraryListResponse])
async def search_steps(
q: str = Query(..., min_length=1),
limit: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Full-text search for steps."""
# Use PostgreSQL full-text search
search_query = func.to_tsquery('english', q.replace(' ', ' & '))
query = select(StepLibrary).where(
StepLibrary.is_active == True,
build_visibility_filter(current_user),
func.to_tsvector('english', StepLibrary.title).match(search_query)
).order_by(desc(StepLibrary.rating_average)).limit(limit)
result = await db.execute(query)
steps = result.scalars().all()
response = []
for step in steps:
step_dict = {
"id": step.id,
"title": step.title,
"step_type": step.step_type,
"visibility": step.visibility,
"category_id": step.category_id,
"tags": step.tags,
"usage_count": step.usage_count,
"rating_average": step.rating_average,
"rating_count": step.rating_count,
"is_featured": step.is_featured,
"created_by": step.created_by,
"created_at": step.created_at,
}
if step.category_id:
cat_result = await db.execute(
select(StepCategory.name).where(StepCategory.id == step.category_id)
)
step_dict["category_name"] = cat_result.scalar_one_or_none()
author_result = await db.execute(
select(User.name).where(User.id == step.created_by)
)
step_dict["author_name"] = author_result.scalar_one_or_none()
response.append(StepLibraryListResponse(**step_dict))
return response
@router.get("/tags/popular", response_model=list[PopularTagResponse])
async def get_popular_tags(
limit: int = Query(20, ge=1, le=50),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get popular tags with usage counts."""
# Use unnest to expand arrays and count occurrences
query = select(
func.unnest(StepLibrary.tags).label('tag'),
func.count().label('count')
).where(
StepLibrary.is_active == True,
build_visibility_filter(current_user)
).group_by(
func.unnest(StepLibrary.tags)
).order_by(
desc(func.count())
).limit(limit)
result = await db.execute(query)
tags = result.all()
return [PopularTagResponse(tag=row.tag, count=row.count) for row in tags]
@router.get("/{step_id}", response_model=StepLibraryResponse)
async def get_step(
step_id: UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get a step by ID."""
step = await get_step_or_404(step_id, db, current_user, check_view=True)
response_dict = {
"id": step.id,
"title": step.title,
"step_type": step.step_type,
"content": step.content,
"category_id": step.category_id,
"tags": step.tags,
"visibility": step.visibility,
"created_by": step.created_by,
"team_id": step.team_id,
"usage_count": step.usage_count,
"rating_average": step.rating_average,
"rating_count": step.rating_count,
"helpful_yes": step.helpful_yes,
"helpful_no": step.helpful_no,
"is_featured": step.is_featured,
"is_verified": step.is_verified,
"is_active": step.is_active,
"created_at": step.created_at,
"updated_at": step.updated_at,
}
# Get category name if exists
if step.category_id:
cat_result = await db.execute(
select(StepCategory.name).where(StepCategory.id == step.category_id)
)
response_dict["category_name"] = cat_result.scalar_one_or_none()
# Get author name
author_result = await db.execute(
select(User.name).where(User.id == step.created_by)
)
response_dict["author_name"] = author_result.scalar_one_or_none()
return StepLibraryResponse(**response_dict)
@router.post("", response_model=StepLibraryResponse, status_code=201)
async def create_step(
step_data: StepLibraryCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Create a new step."""
# Validate category if provided
if step_data.category_id:
cat_result = await db.execute(
select(StepCategory).where(
StepCategory.id == step_data.category_id,
StepCategory.is_active == True
)
)
if not cat_result.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Invalid category")
# Team validation: can only set team_id to own team
team_id = step_data.team_id
if team_id and team_id != current_user.team_id and current_user.role != 'admin':
raise HTTPException(status_code=403, detail="Cannot create step for another team")
step = StepLibrary(
title=step_data.title,
step_type=step_data.step_type,
content=step_data.content.model_dump(),
category_id=step_data.category_id,
tags=step_data.tags,
visibility=step_data.visibility,
created_by=current_user.id,
team_id=team_id or current_user.team_id,
)
db.add(step)
await db.commit()
await db.refresh(step)
# Build response
response_dict = {
"id": step.id,
"title": step.title,
"step_type": step.step_type,
"content": step.content,
"category_id": step.category_id,
"tags": step.tags,
"visibility": step.visibility,
"created_by": step.created_by,
"team_id": step.team_id,
"usage_count": step.usage_count,
"rating_average": step.rating_average,
"rating_count": step.rating_count,
"helpful_yes": step.helpful_yes,
"helpful_no": step.helpful_no,
"is_featured": step.is_featured,
"is_verified": step.is_verified,
"is_active": step.is_active,
"created_at": step.created_at,
"updated_at": step.updated_at,
"author_name": current_user.name,
}
if step.category_id:
cat_result = await db.execute(
select(StepCategory.name).where(StepCategory.id == step.category_id)
)
response_dict["category_name"] = cat_result.scalar_one_or_none()
return StepLibraryResponse(**response_dict)
@router.put("/{step_id}", response_model=StepLibraryResponse)
async def update_step(
step_id: UUID,
step_data: StepLibraryUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Update a step (owner or admin only)."""
step = await get_step_or_404(step_id, db, current_user, check_edit=True)
# Validate category if being updated
if step_data.category_id:
cat_result = await db.execute(
select(StepCategory).where(
StepCategory.id == step_data.category_id,
StepCategory.is_active == True
)
)
if not cat_result.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Invalid category")
# Apply updates
update_data = step_data.model_dump(exclude_unset=True)
if 'content' in update_data and update_data['content']:
update_data['content'] = update_data['content'].model_dump() if hasattr(update_data['content'], 'model_dump') else update_data['content']
for field, value in update_data.items():
setattr(step, field, value)
step.updated_at = datetime.now(timezone.utc)
await db.commit()
await db.refresh(step)
# Build response
response_dict = {
"id": step.id,
"title": step.title,
"step_type": step.step_type,
"content": step.content,
"category_id": step.category_id,
"tags": step.tags,
"visibility": step.visibility,
"created_by": step.created_by,
"team_id": step.team_id,
"usage_count": step.usage_count,
"rating_average": step.rating_average,
"rating_count": step.rating_count,
"helpful_yes": step.helpful_yes,
"helpful_no": step.helpful_no,
"is_featured": step.is_featured,
"is_verified": step.is_verified,
"is_active": step.is_active,
"created_at": step.created_at,
"updated_at": step.updated_at,
}
if step.category_id:
cat_result = await db.execute(
select(StepCategory.name).where(StepCategory.id == step.category_id)
)
response_dict["category_name"] = cat_result.scalar_one_or_none()
author_result = await db.execute(
select(User.name).where(User.id == step.created_by)
)
response_dict["author_name"] = author_result.scalar_one_or_none()
return StepLibraryResponse(**response_dict)
@router.delete("/{step_id}", status_code=204)
async def delete_step(
step_id: UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Soft delete a step (owner or admin only)."""
step = await get_step_or_404(step_id, db, current_user, check_edit=True)
step.is_active = False
step.updated_at = datetime.now(timezone.utc)
await db.commit()
return None
# Rating endpoints
@router.post("/{step_id}/rate", response_model=StepRatingResponse, status_code=201)
async def rate_step(
step_id: UUID,
rating_data: StepRatingCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Rate a step (1-5 stars with optional review)."""
step = await get_step_or_404(step_id, db, current_user, check_view=True)
# Check if user already rated
existing = await db.execute(
select(StepRating).where(
StepRating.step_id == step_id,
StepRating.user_id == current_user.id
)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="You have already rated this step. Use PUT to update.")
rating = StepRating(
step_id=step_id,
user_id=current_user.id,
rating=rating_data.rating,
was_helpful=rating_data.was_helpful,
review_text=rating_data.review_text,
session_id=rating_data.session_id,
is_verified_use=rating_data.session_id is not None,
)
db.add(rating)
# Update aggregated ratings on step
await _update_step_ratings(db, step)
await db.commit()
await db.refresh(rating)
return StepRatingResponse(
id=rating.id,
step_id=rating.step_id,
user_id=rating.user_id,
rating=rating.rating,
was_helpful=rating.was_helpful,
review_text=rating.review_text,
is_verified_use=rating.is_verified_use,
session_id=rating.session_id,
is_visible=rating.is_visible,
created_at=rating.created_at,
updated_at=rating.updated_at,
user_name=current_user.name,
)
@router.put("/{step_id}/rate", response_model=StepRatingResponse)
async def update_rating(
step_id: UUID,
rating_data: StepRatingUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Update your rating for a step."""
step = await get_step_or_404(step_id, db, current_user, check_view=True)
result = await db.execute(
select(StepRating).where(
StepRating.step_id == step_id,
StepRating.user_id == current_user.id
)
)
rating = result.scalar_one_or_none()
if not rating:
raise HTTPException(status_code=404, detail="Rating not found. Use POST to create.")
update_data = rating_data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(rating, field, value)
rating.updated_at = datetime.now(timezone.utc)
# Update aggregated ratings on step
await _update_step_ratings(db, step)
await db.commit()
await db.refresh(rating)
return StepRatingResponse(
id=rating.id,
step_id=rating.step_id,
user_id=rating.user_id,
rating=rating.rating,
was_helpful=rating.was_helpful,
review_text=rating.review_text,
is_verified_use=rating.is_verified_use,
session_id=rating.session_id,
is_visible=rating.is_visible,
created_at=rating.created_at,
updated_at=rating.updated_at,
user_name=current_user.name,
)
@router.delete("/{step_id}/rate", status_code=204)
async def delete_rating(
step_id: UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Delete your rating for a step."""
step = await get_step_or_404(step_id, db, current_user, check_view=True)
result = await db.execute(
select(StepRating).where(
StepRating.step_id == step_id,
StepRating.user_id == current_user.id
)
)
rating = result.scalar_one_or_none()
if not rating:
raise HTTPException(status_code=404, detail="Rating not found")
await db.delete(rating)
# Update aggregated ratings on step
await _update_step_ratings(db, step)
await db.commit()
return None
@router.get("/{step_id}/reviews", response_model=list[StepRatingResponse])
async def get_reviews(
step_id: UUID,
limit: int = Query(20, ge=1, le=100),
offset: int = Query(0, ge=0),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get reviews for a step."""
await get_step_or_404(step_id, db, current_user, check_view=True)
result = await db.execute(
select(StepRating).where(
StepRating.step_id == step_id,
StepRating.is_visible == True,
StepRating.review_text.isnot(None)
).order_by(desc(StepRating.created_at)).offset(offset).limit(limit)
)
ratings = result.scalars().all()
response = []
for rating in ratings:
user_result = await db.execute(
select(User.name).where(User.id == rating.user_id)
)
user_name = user_result.scalar_one_or_none()
response.append(StepRatingResponse(
id=rating.id,
step_id=rating.step_id,
user_id=rating.user_id,
rating=rating.rating,
was_helpful=rating.was_helpful,
review_text=rating.review_text,
is_verified_use=rating.is_verified_use,
session_id=rating.session_id,
is_visible=rating.is_visible,
created_at=rating.created_at,
updated_at=rating.updated_at,
user_name=user_name,
))
return response
async def _update_step_ratings(db: AsyncSession, step: StepLibrary):
"""Update aggregated rating fields on a step."""
# Calculate new aggregates
result = await db.execute(
select(
func.count(StepRating.id),
func.avg(StepRating.rating),
func.sum(case((StepRating.was_helpful == True, 1), else_=0)),
func.sum(case((StepRating.was_helpful == False, 1), else_=0))
).where(StepRating.step_id == step.id)
)
row = result.one()
step.rating_count = row[0] or 0
step.rating_average = Decimal(str(round(row[1] or 0, 2)))
step.helpful_yes = row[2] or 0
step.helpful_no = row[3] or 0

View File

@@ -1,5 +1,5 @@
from fastapi import APIRouter
from app.api.endpoints import auth, trees, sessions, invite, categories, tags, folders
from app.api.endpoints import auth, trees, sessions, invite, categories, tags, folders, step_categories, steps
api_router = APIRouter()
@@ -10,3 +10,5 @@ api_router.include_router(invite.router)
api_router.include_router(categories.router)
api_router.include_router(tags.router)
api_router.include_router(folders.router)
api_router.include_router(step_categories.router)
api_router.include_router(steps.router)

View File

@@ -40,6 +40,8 @@ class Settings(BaseSettings):
# CORS - set FRONTEND_URL in production (e.g., https://patherly.up.railway.app)
CORS_ORIGINS: list[str] = ["http://localhost:3000", "http://localhost:5173", "http://localhost:5174"]
FRONTEND_URL: Optional[str] = None
# Allow all Railway PR environments (set to True in Railway env vars)
ALLOW_RAILWAY_ORIGINS: bool = False
@property
def allowed_origins(self) -> list[str]:
@@ -49,6 +51,15 @@ class Settings(BaseSettings):
origins.append(self.FRONTEND_URL)
return origins
def is_origin_allowed(self, origin: str) -> bool:
"""Check if an origin is allowed, including Railway wildcard pattern."""
if origin in self.allowed_origins:
return True
# Allow any *.up.railway.app origin for PR environments
if self.ALLOW_RAILWAY_ORIGINS and origin.endswith(".up.railway.app"):
return True
return False
class Config:
env_file = ".env"
case_sensitive = True

View File

@@ -20,6 +20,7 @@ async def lifespan(app: FastAPI):
# Startup
logger.info("Starting Patherly API server...")
logger.info(f"Environment: {'Development' if settings.DEBUG else 'Production'}")
logger.info(f"ALLOW_RAILWAY_ORIGINS: {settings.ALLOW_RAILWAY_ORIGINS}")
# Note: In production, use Alembic migrations instead of init_db
# await init_db()
yield
@@ -41,14 +42,33 @@ app = FastAPI(
app.add_middleware(ErrorLoggingMiddleware)
app.add_middleware(RequestLoggingMiddleware)
# Configure CORS
app.add_middleware(
CORSMiddleware,
allow_origins=settings.allowed_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Configure CORS with dynamic origin checking for Railway PR environments
def get_allowed_origins():
"""Return origins list or callable for dynamic checking."""
if settings.ALLOW_RAILWAY_ORIGINS:
# Use callable to dynamically check Railway origins
def check_origin(origin: str) -> bool:
return settings.is_origin_allowed(origin)
return check_origin
return settings.allowed_origins
# Note: When ALLOW_RAILWAY_ORIGINS is True, we use allow_origin_regex for Railway domains
if settings.ALLOW_RAILWAY_ORIGINS:
app.add_middleware(
CORSMiddleware,
allow_origin_regex=r"https://.*\.up\.railway\.app",
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
else:
app.add_middleware(
CORSMiddleware,
allow_origins=settings.allowed_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Include API router
app.include_router(api_router, prefix=settings.API_V1_PREFIX)
@@ -68,3 +88,13 @@ async def root():
async def health_check():
"""Health check endpoint."""
return {"status": "healthy"}
@app.get("/debug/cors")
async def debug_cors():
"""Debug endpoint to check CORS configuration."""
return {
"allow_railway_origins": settings.ALLOW_RAILWAY_ORIGINS,
"cors_mode": "regex" if settings.ALLOW_RAILWAY_ORIGINS else "list",
"allowed_origins": settings.allowed_origins if not settings.ALLOW_RAILWAY_ORIGINS else "*.up.railway.app (regex)"
}

View File

@@ -7,6 +7,8 @@ from .invite_code import InviteCode
from .category import TreeCategory
from .tag import TreeTag, tree_tag_assignments
from .folder import UserFolder, user_folder_trees
from .step_category import StepCategory
from .step_library import StepLibrary, StepRating, StepUsageLog
__all__ = [
"User",
@@ -20,4 +22,8 @@ __all__ = [
"tree_tag_assignments",
"UserFolder",
"user_folder_trees",
"StepCategory",
"StepLibrary",
"StepRating",
"StepUsageLog",
]

View File

@@ -0,0 +1,64 @@
import uuid
from datetime import datetime, timezone
from typing import Optional, TYPE_CHECKING
from sqlalchemy import String, Text, DateTime, ForeignKey, Boolean, Integer, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
from app.core.database import Base
if TYPE_CHECKING:
from app.models.team import Team
from app.models.user import User
class StepCategory(Base):
"""Admin-managed categories for organizing step library entries.
Categories can be:
- Global (team_id=NULL): Created by Patherly admins, visible to all
- Team-specific (team_id set): Created by team admins, visible to team members
"""
__tablename__ = "step_categories"
__table_args__ = (
UniqueConstraint('slug', 'team_id', name='uq_step_categories_slug_team'),
)
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
default=uuid.uuid4
)
name: Mapped[str] = mapped_column(String(100), nullable=False)
slug: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
team_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("teams.id", ondelete="CASCADE"),
nullable=True,
index=True
)
display_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0, index=True)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
created_by: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc)
)
# Relationships
team: Mapped[Optional["Team"]] = relationship("Team", back_populates="step_categories")
creator: Mapped[Optional["User"]] = relationship("User", foreign_keys=[created_by])
@property
def is_global(self) -> bool:
"""Returns True if this is a global category (not team-specific)."""
return self.team_id is None

View File

@@ -0,0 +1,177 @@
import uuid
from datetime import datetime, timezone
from decimal import Decimal
from typing import TYPE_CHECKING, Optional
from sqlalchemy import String, DateTime, Integer, Boolean, Text, Numeric, ForeignKey, CheckConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID, JSONB, ARRAY
from app.core.database import Base
if TYPE_CHECKING:
from app.models.user import User
from app.models.team import Team
from app.models.step_category import StepCategory
from app.models.session import Session
class StepLibrary(Base):
__tablename__ = "step_library"
__table_args__ = (
CheckConstraint(
"step_type IN ('decision', 'action', 'solution')",
name='ck_step_library_step_type'
),
)
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
default=uuid.uuid4
)
title: Mapped[str] = mapped_column(String(255), nullable=False)
step_type: Mapped[str] = mapped_column(String(50), nullable=False)
content: Mapped[dict] = mapped_column(JSONB, nullable=False)
# Ownership
created_by: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False
)
team_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("teams.id", ondelete="CASCADE"),
nullable=True
)
# Organization
category_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("step_categories.id", ondelete="SET NULL"),
nullable=True
)
tags: Mapped[list[str]] = mapped_column(
ARRAY(String(100)),
nullable=False,
default=list
)
# Visibility: 'private', 'team', 'public'
visibility: Mapped[str] = mapped_column(
String(50),
nullable=False,
default="private"
)
# Aggregated ratings
usage_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
rating_average: Mapped[Decimal] = mapped_column(Numeric(3, 2), nullable=False, default=Decimal("0"))
rating_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
helpful_yes: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
helpful_no: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# Flags
is_featured: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
is_verified: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
# Timestamps
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc)
)
# Soft delete
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
# Relationships
creator: Mapped["User"] = relationship("User", foreign_keys=[created_by])
team: Mapped[Optional["Team"]] = relationship("Team")
category: Mapped[Optional["StepCategory"]] = relationship("StepCategory")
ratings: Mapped[list["StepRating"]] = relationship("StepRating", back_populates="step", cascade="all, delete-orphan")
usage_logs: Mapped[list["StepUsageLog"]] = relationship("StepUsageLog", back_populates="step", cascade="all, delete-orphan")
class StepRating(Base):
__tablename__ = "step_ratings"
__table_args__ = (
CheckConstraint('rating >= 1 AND rating <= 5', name='ck_step_ratings_rating_range'),
)
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
default=uuid.uuid4
)
step_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("step_library.id", ondelete="CASCADE"),
nullable=False
)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False
)
rating: Mapped[int] = mapped_column(Integer, nullable=False)
was_helpful: Mapped[Optional[bool]] = mapped_column(Boolean, nullable=True)
review_text: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
is_verified_use: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
session_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("sessions.id", ondelete="SET NULL"),
nullable=True
)
is_visible: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc)
)
# Relationships
step: Mapped["StepLibrary"] = relationship("StepLibrary", back_populates="ratings")
user: Mapped["User"] = relationship("User")
session: Mapped[Optional["Session"]] = relationship("Session")
class StepUsageLog(Base):
__tablename__ = "step_usage_log"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
default=uuid.uuid4
)
step_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("step_library.id", ondelete="CASCADE"),
nullable=False
)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False
)
session_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("sessions.id", ondelete="CASCADE"),
nullable=False
)
used_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc)
)
# Relationships
step: Mapped["StepLibrary"] = relationship("StepLibrary", back_populates="usage_logs")
user: Mapped["User"] = relationship("User")
session: Mapped["Session"] = relationship("Session")

View File

@@ -11,6 +11,7 @@ if TYPE_CHECKING:
from app.models.tree import Tree
from app.models.category import TreeCategory
from app.models.tag import TreeTag
from app.models.step_category import StepCategory
class Team(Base):
@@ -32,3 +33,4 @@ class Team(Base):
trees: Mapped[list["Tree"]] = relationship("Tree", back_populates="team")
categories: Mapped[list["TreeCategory"]] = relationship("TreeCategory", back_populates="team")
tags: Mapped[list["TreeTag"]] = relationship("TreeTag", back_populates="team")
step_categories: Mapped[list["StepCategory"]] = relationship("StepCategory", back_populates="team")

View File

@@ -0,0 +1,58 @@
from datetime import datetime
from typing import Optional
from uuid import UUID
from pydantic import BaseModel, Field
import re
def slugify(name: str) -> str:
"""Convert a name to a URL-safe slug."""
# Remove non-alphanumeric chars except spaces, convert to lowercase
slug = re.sub(r'[^a-zA-Z0-9 ]', '', name.lower())
# Replace spaces with hyphens
slug = re.sub(r' +', '-', slug.strip())
return slug
class StepCategoryBase(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
description: Optional[str] = None
class StepCategoryCreate(StepCategoryBase):
team_id: Optional[UUID] = Field(None, description="Team ID for team-specific category. NULL for global.")
class StepCategoryUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=100)
description: Optional[str] = None
display_order: Optional[int] = None
is_active: Optional[bool] = None
class StepCategoryResponse(StepCategoryBase):
id: UUID
slug: str
team_id: Optional[UUID] = None
display_order: int
is_active: bool
created_at: datetime
updated_at: datetime
step_count: int = 0 # Computed field - count of steps in this category
class Config:
from_attributes = True
class StepCategoryListResponse(BaseModel):
id: UUID
name: str
slug: str
description: Optional[str] = None
team_id: Optional[UUID] = None
display_order: int
is_active: bool
step_count: int = 0
class Config:
from_attributes = True

View File

@@ -0,0 +1,135 @@
from datetime import datetime
from decimal import Decimal
from typing import Optional, Literal
from uuid import UUID
from pydantic import BaseModel, Field
class StepCommand(BaseModel):
"""A command that can be run as part of a step."""
label: str
command: str
command_type: Optional[str] = None # e.g., 'powershell', 'cmd', 'bash'
class StepContent(BaseModel):
"""Content structure for step library entries (stored as JSONB)."""
instructions: str = Field(..., min_length=1)
help_text: Optional[str] = None
commands: Optional[list[StepCommand]] = None
# Base schemas
class StepLibraryBase(BaseModel):
title: str = Field(..., min_length=1, max_length=255)
step_type: Literal['decision', 'action', 'solution']
content: StepContent
category_id: Optional[UUID] = None
tags: list[str] = Field(default_factory=list)
visibility: Literal['private', 'team', 'public'] = 'private'
class StepLibraryCreate(StepLibraryBase):
team_id: Optional[UUID] = None
class StepLibraryUpdate(BaseModel):
title: Optional[str] = Field(None, min_length=1, max_length=255)
step_type: Optional[Literal['decision', 'action', 'solution']] = None
content: Optional[StepContent] = None
category_id: Optional[UUID] = None
tags: Optional[list[str]] = None
visibility: Optional[Literal['private', 'team', 'public']] = None
class StepLibraryResponse(StepLibraryBase):
id: UUID
created_by: UUID
team_id: Optional[UUID] = None
usage_count: int
rating_average: Decimal
rating_count: int
helpful_yes: int
helpful_no: int
is_featured: bool
is_verified: bool
is_active: bool
created_at: datetime
updated_at: datetime
# Computed fields (populated by API)
category_name: Optional[str] = None
author_name: Optional[str] = None
class Config:
from_attributes = True
class StepLibraryListResponse(BaseModel):
id: UUID
title: str
step_type: str
visibility: str
category_id: Optional[UUID] = None
category_name: Optional[str] = None
tags: list[str]
usage_count: int
rating_average: Decimal
rating_count: int
is_featured: bool
created_by: UUID
author_name: Optional[str] = None
created_at: datetime
class Config:
from_attributes = True
# Rating schemas
class StepRatingBase(BaseModel):
rating: int = Field(..., ge=1, le=5)
was_helpful: Optional[bool] = None
review_text: Optional[str] = Field(None, max_length=500)
class StepRatingCreate(StepRatingBase):
session_id: Optional[UUID] = None # For verified use tracking
class StepRatingUpdate(BaseModel):
rating: Optional[int] = Field(None, ge=1, le=5)
was_helpful: Optional[bool] = None
review_text: Optional[str] = Field(None, max_length=500)
class StepRatingResponse(StepRatingBase):
id: UUID
step_id: UUID
user_id: UUID
is_verified_use: bool
session_id: Optional[UUID] = None
is_visible: bool
created_at: datetime
updated_at: datetime
# Computed
user_name: Optional[str] = None
class Config:
from_attributes = True
# Search and filter schemas
class StepSearchParams(BaseModel):
q: Optional[str] = None # Full-text search query
category_id: Optional[UUID] = None
tags: Optional[list[str]] = None
min_rating: Optional[float] = Field(None, ge=0, le=5)
step_type: Optional[Literal['decision', 'action', 'solution']] = None
visibility: Optional[Literal['private', 'team', 'public']] = None
sort_by: Literal['recent', 'popular', 'highest_rated', 'most_used'] = 'recent'
limit: int = Field(20, ge=1, le=100)
offset: int = Field(0, ge=0)
class PopularTagResponse(BaseModel):
tag: str
count: int

View File

@@ -16,6 +16,7 @@ export function AppLayout() {
const navItems = [
{ path: '/trees', label: 'Trees' },
{ path: '/sessions', label: 'Sessions' },
{ path: '/settings', label: 'Settings' },
]
return (

View File

@@ -3,17 +3,19 @@ import { useParams, useNavigate } from 'react-router-dom'
import { Copy, Check, Eye } from 'lucide-react'
import { sessionsApi } from '@/api'
import { ExportPreviewModal } from '@/components/session/ExportPreviewModal'
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
import type { Session, SessionExport } from '@/types'
import { cn } from '@/lib/utils'
export function SessionDetailPage() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const { defaultExportFormat } = useUserPreferencesStore()
const [session, setSession] = useState<Session | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [isExporting, setIsExporting] = useState(false)
const [exportFormat, setExportFormat] = useState<'markdown' | 'text' | 'html'>('markdown')
const [exportFormat, setExportFormat] = useState<'markdown' | 'text' | 'html'>(defaultExportFormat)
const [exportContent, setExportContent] = useState<string | null>(null)
const [showPreview, setShowPreview] = useState(false)
const [copied, setCopied] = useState(false)

View File

@@ -0,0 +1,93 @@
import { Settings } from 'lucide-react'
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
import { useThemeStore } from '@/store/themeStore'
import { cn } from '@/lib/utils'
import { ThemeToggle } from '@/components/common/ThemeToggle'
export function SettingsPage() {
const { defaultExportFormat, setDefaultExportFormat } = useUserPreferencesStore()
const { theme } = useThemeStore()
return (
<div className="container mx-auto px-4 py-8">
<div className="mb-8">
<div className="flex items-center gap-3">
<Settings className="h-8 w-8 text-primary" />
<h1 className="text-3xl font-bold text-foreground">Settings</h1>
</div>
<p className="mt-2 text-muted-foreground">
Manage your application preferences
</p>
</div>
<div className="max-w-2xl space-y-6">
{/* Appearance Section */}
<div className="rounded-lg border border-border bg-card p-6 shadow-sm">
<h2 className="text-lg font-semibold text-card-foreground">Appearance</h2>
<p className="mt-1 text-sm text-muted-foreground">
Customize how Patherly looks on your device
</p>
<div className="mt-4">
<label className="block text-sm font-medium text-card-foreground">
Theme
</label>
<p className="text-sm text-muted-foreground">
Current: {theme.charAt(0).toUpperCase() + theme.slice(1)}
</p>
<div className="mt-2">
<ThemeToggle />
</div>
</div>
</div>
{/* Export Preferences Section */}
<div className="rounded-lg border border-border bg-card p-6 shadow-sm">
<h2 className="text-lg font-semibold text-card-foreground">Export Preferences</h2>
<p className="mt-1 text-sm text-muted-foreground">
Configure default settings for session exports
</p>
<div className="mt-4">
<label
htmlFor="export-format"
className="block text-sm font-medium text-card-foreground"
>
Default Export Format
</label>
<p className="text-sm text-muted-foreground">
This format will be pre-selected when exporting sessions
</p>
<select
id="export-format"
value={defaultExportFormat}
onChange={(e) => setDefaultExportFormat(e.target.value as 'markdown' | 'text' | 'html')}
className={cn(
'mt-2 block w-full rounded-md border border-input bg-background px-3 py-2',
'text-sm text-foreground',
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary'
)}
>
<option value="markdown">Markdown (.md)</option>
<option value="text">Plain Text (.txt)</option>
<option value="html">HTML (.html)</option>
</select>
</div>
</div>
{/* About Section */}
<div className="rounded-lg border border-border bg-card p-6 shadow-sm">
<h2 className="text-lg font-semibold text-card-foreground">About</h2>
<p className="mt-1 text-sm text-muted-foreground">
Patherly - Troubleshooting Decision Trees
</p>
<p className="mt-2 text-sm text-muted-foreground">
"Take the path MOST traveled."
</p>
</div>
</div>
</div>
)
}
export default SettingsPage

View File

@@ -5,3 +5,4 @@ export { default as TreeNavigationPage } from './TreeNavigationPage'
export { default as TreeEditorPage } from './TreeEditorPage'
export { default as SessionHistoryPage } from './SessionHistoryPage'
export { default as SessionDetailPage } from './SessionDetailPage'
export { default as SettingsPage } from './SettingsPage'

View File

@@ -9,6 +9,7 @@ import {
TreeEditorPage,
SessionHistoryPage,
SessionDetailPage,
SettingsPage,
} from '@/pages'
export const router = createBrowserRouter([
@@ -59,6 +60,10 @@ export const router = createBrowserRouter([
path: 'sessions/:id',
element: <SessionDetailPage />,
},
{
path: 'settings',
element: <SettingsPage />,
},
],
},
])

View File

@@ -0,0 +1,23 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
type ExportFormat = 'markdown' | 'text' | 'html'
interface UserPreferencesState {
defaultExportFormat: ExportFormat
setDefaultExportFormat: (format: ExportFormat) => void
}
export const useUserPreferencesStore = create<UserPreferencesState>()(
persist(
(set) => ({
defaultExportFormat: 'markdown',
setDefaultExportFormat: (format) => set({ defaultExportFormat: format }),
}),
{
name: 'user-preferences-storage',
}
)
)
export default useUserPreferencesStore