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:
91
CLAUDE.md
91
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
|
||||
|
||||
67
backend/alembic/versions/007_add_step_categories.py
Normal file
67
backend/alembic/versions/007_add_step_categories.py
Normal 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')
|
||||
97
backend/alembic/versions/008_add_step_library.py
Normal file
97
backend/alembic/versions/008_add_step_library.py
Normal 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')
|
||||
288
backend/app/api/endpoints/step_categories.py
Normal file
288
backend/app/api/endpoints/step_categories.py
Normal 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
|
||||
649
backend/app/api/endpoints/steps.py
Normal file
649
backend/app/api/endpoints/steps.py
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
64
backend/app/models/step_category.py
Normal file
64
backend/app/models/step_category.py
Normal 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
|
||||
177
backend/app/models/step_library.py
Normal file
177
backend/app/models/step_library.py
Normal 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")
|
||||
@@ -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")
|
||||
|
||||
58
backend/app/schemas/step_category.py
Normal file
58
backend/app/schemas/step_category.py
Normal 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
|
||||
135
backend/app/schemas/step_library.py
Normal file
135
backend/app/schemas/step_library.py
Normal 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
|
||||
@@ -16,6 +16,7 @@ export function AppLayout() {
|
||||
const navItems = [
|
||||
{ path: '/trees', label: 'Trees' },
|
||||
{ path: '/sessions', label: 'Sessions' },
|
||||
{ path: '/settings', label: 'Settings' },
|
||||
]
|
||||
|
||||
return (
|
||||
|
||||
@@ -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)
|
||||
|
||||
93
frontend/src/pages/SettingsPage.tsx
Normal file
93
frontend/src/pages/SettingsPage.tsx
Normal 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
|
||||
@@ -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'
|
||||
|
||||
@@ -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 />,
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
23
frontend/src/store/userPreferencesStore.ts
Normal file
23
frontend/src/store/userPreferencesStore.ts
Normal 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
|
||||
Reference in New Issue
Block a user