Add step library foundation and user preferences (#24)

## Summary
Implements Phase 2.5 Step Library Foundation:

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

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

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

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

View File

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

View File

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