Add step library foundation and user preferences (Issues #3, #5, #6, #7)

Issue #3: User Preferences - export format default
- Add userPreferencesStore with localStorage persistence
- Create Settings page with export format dropdown and theme toggle
- SessionDetailPage now uses default export format from preferences

Issue #5: Step Categories - database table and seed data
- Migration 007: step_categories table with team scoping
- Seed 10 default global categories (Citrix/VDI, AD, M365, etc.)
- Full CRUD API at /api/v1/step-categories

Issue #6: Step Library - database schema
- Migration 008: step_library, step_ratings, step_usage_log tables
- Support for decision/action/solution step types
- Visibility levels: private, team, public
- Rating aggregates and usage tracking

Issue #7: Step Library - CRUD API endpoints
- Full CRUD at /api/v1/steps with visibility filtering
- Full-text search endpoint
- Popular tags endpoint
- Rating/review system with verified use tracking

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
chihlasm
2026-02-03 01:25:31 -05:00
parent 1e4eec00e2
commit c782d41eff
17 changed files with 1672 additions and 2 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')