diff --git a/CLAUDE.md b/CLAUDE.md index 769095c4..97213db4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/backend/alembic/versions/007_add_step_categories.py b/backend/alembic/versions/007_add_step_categories.py new file mode 100644 index 00000000..9ecffee1 --- /dev/null +++ b/backend/alembic/versions/007_add_step_categories.py @@ -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') diff --git a/backend/alembic/versions/008_add_step_library.py b/backend/alembic/versions/008_add_step_library.py new file mode 100644 index 00000000..94a88e72 --- /dev/null +++ b/backend/alembic/versions/008_add_step_library.py @@ -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') diff --git a/backend/app/api/endpoints/step_categories.py b/backend/app/api/endpoints/step_categories.py new file mode 100644 index 00000000..c21ab917 --- /dev/null +++ b/backend/app/api/endpoints/step_categories.py @@ -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 diff --git a/backend/app/api/endpoints/steps.py b/backend/app/api/endpoints/steps.py new file mode 100644 index 00000000..48291639 --- /dev/null +++ b/backend/app/api/endpoints/steps.py @@ -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 diff --git a/backend/app/api/router.py b/backend/app/api/router.py index 9031c707..cc438015 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -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) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index a254b10e..25525df0 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -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 diff --git a/backend/app/main.py b/backend/app/main.py index 5d963379..47f10349 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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)" + } diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 5285938f..64da6b6b 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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", ] diff --git a/backend/app/models/step_category.py b/backend/app/models/step_category.py new file mode 100644 index 00000000..286603db --- /dev/null +++ b/backend/app/models/step_category.py @@ -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 diff --git a/backend/app/models/step_library.py b/backend/app/models/step_library.py new file mode 100644 index 00000000..5fb78614 --- /dev/null +++ b/backend/app/models/step_library.py @@ -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") diff --git a/backend/app/models/team.py b/backend/app/models/team.py index 9f36534f..299c9cb9 100644 --- a/backend/app/models/team.py +++ b/backend/app/models/team.py @@ -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") diff --git a/backend/app/schemas/step_category.py b/backend/app/schemas/step_category.py new file mode 100644 index 00000000..9d03667e --- /dev/null +++ b/backend/app/schemas/step_category.py @@ -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 diff --git a/backend/app/schemas/step_library.py b/backend/app/schemas/step_library.py new file mode 100644 index 00000000..dbd7357a --- /dev/null +++ b/backend/app/schemas/step_library.py @@ -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 diff --git a/frontend/src/components/layout/AppLayout.tsx b/frontend/src/components/layout/AppLayout.tsx index d69d9195..70454100 100644 --- a/frontend/src/components/layout/AppLayout.tsx +++ b/frontend/src/components/layout/AppLayout.tsx @@ -16,6 +16,7 @@ export function AppLayout() { const navItems = [ { path: '/trees', label: 'Trees' }, { path: '/sessions', label: 'Sessions' }, + { path: '/settings', label: 'Settings' }, ] return ( diff --git a/frontend/src/pages/SessionDetailPage.tsx b/frontend/src/pages/SessionDetailPage.tsx index 97014d99..6de8a37e 100644 --- a/frontend/src/pages/SessionDetailPage.tsx +++ b/frontend/src/pages/SessionDetailPage.tsx @@ -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(null) const [isLoading, setIsLoading] = useState(true) const [error, setError] = useState(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(null) const [showPreview, setShowPreview] = useState(false) const [copied, setCopied] = useState(false) diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx new file mode 100644 index 00000000..3051cd76 --- /dev/null +++ b/frontend/src/pages/SettingsPage.tsx @@ -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 ( +
+
+
+ +

Settings

+
+

+ Manage your application preferences +

+
+ +
+ {/* Appearance Section */} +
+

Appearance

+

+ Customize how Patherly looks on your device +

+ +
+ +

+ Current: {theme.charAt(0).toUpperCase() + theme.slice(1)} +

+
+ +
+
+
+ + {/* Export Preferences Section */} +
+

Export Preferences

+

+ Configure default settings for session exports +

+ +
+ +

+ This format will be pre-selected when exporting sessions +

+ +
+
+ + {/* About Section */} +
+

About

+

+ Patherly - Troubleshooting Decision Trees +

+

+ "Take the path MOST traveled." +

+
+
+
+ ) +} + +export default SettingsPage diff --git a/frontend/src/pages/index.ts b/frontend/src/pages/index.ts index 20269f01..849334f3 100644 --- a/frontend/src/pages/index.ts +++ b/frontend/src/pages/index.ts @@ -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' diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index adfc4d1e..883d495f 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -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: , }, + { + path: 'settings', + element: , + }, ], }, ]) diff --git a/frontend/src/store/userPreferencesStore.ts b/frontend/src/store/userPreferencesStore.ts new file mode 100644 index 00000000..ee338b87 --- /dev/null +++ b/frontend/src/store/userPreferencesStore.ts @@ -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()( + persist( + (set) => ({ + defaultExportFormat: 'markdown', + setDefaultExportFormat: (format) => set({ defaultExportFormat: format }), + }), + { + name: 'user-preferences-storage', + } + ) +) + +export default useUserPreferencesStore