diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..c741088d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,563 @@ +# 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 + +--- + +## Project Overview + +**Patherly** is a troubleshooting decision tree application designed for MSP engineers. It guides engineers through proven troubleshooting paths, captures decisions and notes automatically, and generates professional ticket documentation. + +**Tagline:** "Take the path MOST traveled." + +**Primary User:** Michael Chihlas - Senior Systems Engineer at an MSP + +**Goal:** Michael uses this tool for 50% of his tickets within 3 months. + +--- + +## 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 +- **Database:** PostgreSQL with Docker (container name: `patherly_postgres`) + +### What's Complete +- User authentication (JWT, register, login, refresh, invite codes) +- Trees CRUD with full-text search +- Sessions tracking with decisions +- Export API (Markdown, Text, HTML) +- Tree Editor with form-based editing and visual preview +- Dark/Light theme toggle +- Markdown rendering in session player and node editor +- 7 comprehensive seed decision trees +- **Tree Organization System:** + - Categories (global + team-specific, admin-managed) + - Tags (author + admin managed, autocomplete) + - User folders (personal tree collections) + - Subfolder hierarchy (max 3 levels deep) + - Right-click context menu for edit/delete/add subfolder + - Cascade delete for subfolders + - Team admin role with scoped permissions + - Filter trees by category, tags, and folders + +### What's In Progress +- User preferences (export format default) +- Deployment to Railway/Render + +--- + +## Tech Stack + +### Backend +- **Framework:** Python FastAPI +- **Database:** PostgreSQL 16 (async via SQLAlchemy 2.0 + asyncpg) +- **Migrations:** Alembic +- **Auth:** JWT tokens (python-jose) + bcrypt passwords +- **Validation:** Pydantic v2 + +### Frontend +- **Framework:** React 19 + Vite + TypeScript +- **Styling:** Tailwind CSS v3 +- **State:** Zustand (with immer + zundo for undo/redo) +- **Routing:** React Router v7 +- **API Client:** Axios with token interceptors +- **Icons:** Lucide React + +--- + +## Project Structure + +``` +patherly/ +├── backend/ +│ ├── app/ +│ │ ├── main.py # FastAPI entry point +│ │ ├── api/ +│ │ │ ├── endpoints/ +│ │ │ │ ├── auth.py # Auth: register, login, refresh, logout +│ │ │ │ ├── trees.py # Trees CRUD + search +│ │ │ │ ├── sessions.py # Sessions + export +│ │ │ │ └── invite.py # Invite code management +│ │ │ ├── deps.py # Auth dependencies +│ │ │ └── router.py +│ │ ├── core/ +│ │ │ ├── config.py # Settings (pydantic-settings) +│ │ │ ├── database.py # Async SQLAlchemy +│ │ │ ├── security.py # JWT + password hashing +│ │ │ ├── logging_config.py # Structured logging +│ │ │ └── middleware.py # Request logging +│ │ ├── models/ # SQLAlchemy models +│ │ │ ├── user.py # is_team_admin field added +│ │ │ ├── team.py +│ │ │ ├── tree.py # JSONB tree_structure + category_id, tags +│ │ │ ├── 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) +│ │ └── schemas/ # Pydantic schemas +│ ├── alembic/ # Database migrations +│ ├── scripts/ +│ │ ├── seed_data.py +│ │ └── seed_trees.py # 7 comprehensive trees +│ ├── tests/ # pytest integration tests +│ ├── docker-compose.yml # PostgreSQL container +│ ├── requirements.txt +│ └── .env # Environment variables +│ +├── frontend/ +│ ├── src/ +│ │ ├── main.tsx +│ │ ├── App.tsx +│ │ ├── router.tsx +│ │ ├── api/ # Axios API client +│ │ │ ├── client.ts # Axios instance with interceptors +│ │ │ ├── auth.ts +│ │ │ ├── trees.ts +│ │ │ └── sessions.ts +│ │ ├── store/ +│ │ │ ├── authStore.ts # Zustand auth state +│ │ │ ├── themeStore.ts # Dark/light theme +│ │ │ └── treeEditorStore.ts # Tree editor state (immer + zundo) +│ │ ├── components/ +│ │ │ ├── common/ # Modal, ErrorBoundary, ThemeToggle +│ │ │ ├── layout/ # AppLayout, ProtectedRoute +│ │ │ ├── tree-editor/ # Tree editor components +│ │ │ ├── tree-preview/ # Visual tree preview +│ │ │ └── ui/ # MarkdownContent +│ │ ├── pages/ +│ │ │ ├── LoginPage.tsx +│ │ │ ├── RegisterPage.tsx +│ │ │ ├── TreeLibraryPage.tsx +│ │ │ ├── TreeNavigationPage.tsx # Core feature +│ │ │ ├── TreeEditorPage.tsx +│ │ │ ├── SessionHistoryPage.tsx +│ │ │ └── SessionDetailPage.tsx +│ │ ├── types/ # TypeScript interfaces +│ │ └── lib/utils.ts # cn() utility for Tailwind +│ ├── package.json +│ ├── tailwind.config.js +│ └── vite.config.ts +│ +├── CLAUDE.md # This file +├── CURRENT-STATE.md # Quick status reference +├── LESSONS-LEARNED.md # Bugs and fixes (READ THIS!) +├── PROGRESS.md # Detailed progress log +├── 01-PROJECT-OVERVIEW.md # Vision and goals +├── 02-TECHNICAL-ARCHITECTURE.md # System design, API specs +├── 03-DEVELOPMENT-ROADMAP.md # Phases and timeline +├── 04-FEATURE-SPECIFICATIONS.md # Feature details +├── 05-QUESTIONS-AND-ACTION-ITEMS.md +└── PHASE-2.5-PERSONAL-BRANCHING.md # Future feature spec +``` + +--- + +## Development Commands + +### Start Development Environment + +```powershell +# Terminal 1: Start PostgreSQL +docker start patherly_postgres + +# Terminal 2: Backend +cd C:\Dev\Projects\patherly\patherly\backend +.\venv\Scripts\Activate +uvicorn app.main:app --reload + +# Terminal 3: Frontend +cd C:\Dev\Projects\patherly\patherly\frontend +npm run dev +``` + +### URLs +- Frontend: http://localhost:5173 +- Backend API: http://localhost:8000 +- API Docs: http://localhost:8000/api/docs + +### Run Tests +```powershell +cd C:\Dev\Projects\patherly\patherly\backend +.\venv\Scripts\Activate +pytest +``` + +### Run Seed Scripts +```powershell +cd C:\Dev\Projects\patherly\patherly\backend +.\venv\Scripts\Activate +pip install httpx # Required for seed scripts +python -m scripts.seed_trees +``` + +### Database Operations +```powershell +# Run migrations +cd backend +alembic upgrade head + +# Create new migration +alembic revision --autogenerate -m "Description" + +# Access PostgreSQL (no local psql needed) +docker exec -it patherly_postgres psql -U postgres -d patherly +``` + +--- + +## Critical Lessons Learned + +**ALWAYS read [LESSONS-LEARNED.md](LESSONS-LEARNED.md) before making changes!** + +### DateTime Handling (Critical) +```python +# CORRECT - Always use timezone-aware datetimes +from datetime import datetime, timezone +from sqlalchemy import DateTime + +created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) + +# WRONG - Never use this +datetime.utcnow() # Deprecated, returns naive datetime +``` + +### React State: Don't Store Object Snapshots +```tsx +// WRONG - Snapshot won't update when store changes +const [editingNode, setEditingNode] = useState(null) + +// CORRECT - Store ID only, fetch current object each render +const [editingNodeId, setEditingNodeId] = useState(null) +const editingNode = editingNodeId ? findNode(editingNodeId) : null +``` + +### Modal Draft State: Don't Overwrite Store-Managed Fields +```tsx +// WRONG - Overwrites children with stale snapshot +const handleSave = () => { + updateNode(node.id, draft) // draft.children is stale! +} + +// CORRECT - Exclude store-managed fields +const handleSave = () => { + const { children, ...draftWithoutChildren } = draft + updateNode(node.id, draftWithoutChildren) +} +``` + +### Database Name +- Database name is `patherly` (not `decision_tree`) +- Update `.env` if you see the old name + +### Virtual Environment +- Always check for `(venv)` prefix before running pip +- Don't use `--break-system-packages` when venv is active + +### PostgreSQL NULL Casting for UUID Columns +```sql +-- WRONG - PostgreSQL infers NULL as text type +INSERT INTO tree_tags (name, slug, team_id) +SELECT 'tag', 'slug', NULL as team_id -- Error: column is uuid but expression is text + +-- CORRECT - Explicitly cast NULL to uuid +INSERT INTO tree_tags (name, slug, team_id) +SELECT 'tag', 'slug', NULL::uuid as team_id -- Works! +``` +Always use `NULL::uuid` when inserting NULL values into UUID columns in raw SQL. + +### SQLAlchemy Async: Avoid Lazy Loading on New Objects +```python +# WRONG - Triggers lazy load which fails in async context +new_tree = Tree(...) +db.add(new_tree) +await db.flush() +new_tree.tags.append(tag) # MissingGreenlet error! + +# CORRECT - Use direct SQL for junction tables +from app.models.tag import tree_tag_assignments +await db.execute( + tree_tag_assignments.insert().values( + tree_id=new_tree.id, + tag_id=tag.id + ) +) +``` +Accessing relationships on newly created objects triggers lazy loading, which fails in async SQLAlchemy. Use direct SQL inserts for junction tables instead. + +### React Router: Clear Dirty State Before Navigation +```tsx +// WRONG - Navigation triggers before dirty flag is cleared +const newTree = await treesApi.create(data) +navigate(`/trees/${newTree.id}/edit`) // Blocker fires here! +markSaved() // Too late + +// CORRECT - Clear dirty state first +const newTree = await treesApi.create(data) +markSaved() // Clear isDirty first +navigate(`/trees/${newTree.id}/edit`) // Blocker won't fire +``` +When using `useBlocker` for unsaved changes, always clear the dirty flag before programmatic navigation. + +--- + +## API Endpoints Reference + +### Authentication +``` +POST /api/v1/auth/register - Register (requires invite code by default) +POST /api/v1/auth/login - Login (form data) +POST /api/v1/auth/login/json - Login (JSON body) +POST /api/v1/auth/refresh - Refresh access token +GET /api/v1/auth/me - Get current user +POST /api/v1/auth/logout - Logout +``` + +### Trees +``` +GET /api/v1/trees - List trees (filters: category_id, tags, folder_id) +POST /api/v1/trees - Create tree (supports tags, category_id) +GET /api/v1/trees/categories - Get legacy string categories +GET /api/v1/trees/search - Full-text search +GET /api/v1/trees/{id} - Get tree (includes tags, category_info) +PUT /api/v1/trees/{id} - Update tree +DELETE /api/v1/trees/{id} - Soft delete +``` + +### Categories (NEW) +``` +GET /api/v1/categories - List categories (global + user's team) +POST /api/v1/categories - Create category (team/global admin) +GET /api/v1/categories/{id} - Get category +PUT /api/v1/categories/{id} - Update category +DELETE /api/v1/categories/{id} - Soft delete category +``` + +### Tags (NEW) +``` +GET /api/v1/tags - List tags (global + user's team) +GET /api/v1/tags/search - Autocomplete search +POST /api/v1/tags - Create tag +GET /api/v1/tags/{id} - Get tag +GET /api/v1/tags/trees/{id} - Get tree's tags +POST /api/v1/tags/trees/{id} - Add tags to tree +PUT /api/v1/tags/trees/{id} - Replace tree's tags +DELETE /api/v1/tags/trees/{id}/{slug} - Remove tag from tree +``` + +### Folders (NEW) +``` +GET /api/v1/folders - List user's folders (includes parent_id) +POST /api/v1/folders - Create folder (supports parent_id for subfolders) +GET /api/v1/folders/{id} - Get folder +PUT /api/v1/folders/{id} - Update folder (supports moving via parent_id) +DELETE /api/v1/folders/{id} - Delete folder (cascades to subfolders) +POST /api/v1/folders/reorder - Reorder folders +POST /api/v1/folders/{id}/trees - Add tree to folder +GET /api/v1/folders/{id}/trees - Get folder's tree IDs +DELETE /api/v1/folders/{id}/trees/{tree_id} - Remove tree from folder +``` + +**Folder hierarchy constraints:** +- Max nesting depth: 3 levels (root → child → grandchild) +- Same folder name allowed under different parents +- Moving folders validates cycle prevention + +### Sessions +``` +GET /api/v1/sessions - List user's sessions +POST /api/v1/sessions - Start session +GET /api/v1/sessions/{id} - Get session +PUT /api/v1/sessions/{id} - Update session +POST /api/v1/sessions/{id}/complete - Complete session +POST /api/v1/sessions/{id}/export - Export (md/txt/html) +``` + +### Invite Codes +``` +GET /api/v1/invite-codes - List codes (admin) +POST /api/v1/invite-codes - Create code (admin) +GET /api/v1/invite-codes/validate/{code} - Validate code +``` + +--- + +## Data Models + +### Tree Structure (JSONB) +```typescript +interface TreeStructure { + id: string + type: 'decision' | 'action' | 'solution' + + // Decision nodes + question?: string + help_text?: string + options?: Array<{ + id: string + label: string + next_node_id?: string + }> + + // Action nodes + title?: string + description?: string + commands?: string[] + next_node_id?: string + + // Solution nodes + title?: string + description?: string + steps?: string[] + + // All nodes can have children + children?: TreeStructure[] +} +``` + +### Session Decisions (JSONB) +```typescript +interface Decision { + node_id: string + question?: string + answer: string + notes?: string + timestamp: string // ISO string, not datetime object +} +``` + +--- + +## Frontend Patterns + +### State Management +- **Auth:** `useAuthStore` - Zustand with localStorage persistence +- **Theme:** `useThemeStore` - Dark/light/system preference +- **Tree Editor:** `useTreeEditorStore` - Zustand + immer + zundo (undo/redo) + +### Component Guidelines +- Use `cn()` from `@/lib/utils` for Tailwind class merging +- Use Lucide icons (no `title` prop - wrap in `` instead) +- Modals: Use fixed header/footer with scrollable body +- Forms: Show field-level validation errors + +### API Client Pattern +```typescript +import api from '@/api/client' + +// Token refresh handled automatically by interceptor +const response = await api.get('/api/v1/trees') +``` + +--- + +## Common Tasks + +### Adding a New API Endpoint + +1. Create route in `backend/app/api/endpoints/` +2. Add to router in `backend/app/api/router.py` +3. Create/update Pydantic schemas in `backend/app/schemas/` +4. Add tests in `backend/tests/` +5. Update API client in `frontend/src/api/` + +### Adding a New Page + +1. Create page component in `frontend/src/pages/` +2. Add route in `frontend/src/router.tsx` +3. Add navigation link in `AppLayout.tsx` if needed + +### Modifying Database Schema + +1. Update model in `backend/app/models/` +2. Create migration: `alembic revision --autogenerate -m "description"` +3. Review generated migration file +4. Apply: `alembic upgrade head` + +--- + +## Coding Standards + +### Python (Backend) +- Use type hints everywhere +- Use async/await for database operations +- Use Pydantic for validation +- Log with correlation IDs for tracing +- Always use `DateTime(timezone=True)` for timestamps + +### TypeScript (Frontend) +- Enable strict mode (when ready) +- Use TypeScript interfaces for all data structures +- Prefer `const` over `let` +- Use functional components with hooks +- Extract reusable logic into custom hooks + +### Git +- Commit message format: `type: description` +- Types: `feat`, `fix`, `refactor`, `docs`, `test`, `chore` +- Always include `Co-Authored-By: Claude Opus 4.5 ` + +--- + +## 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 3 (Planned) +- File attachments (screenshots, logs) +- Offline mode (Service Workers + IndexedDB) +- Client context system +- Analytics dashboard + +### Phase 4 (Planned) +- API & integrations (ConnectWise, Kaseya) +- PowerShell automation execution +- Enterprise features (SSO, white-labeling) + +--- + +## Troubleshooting + +### Backend won't start +1. Check Docker: `docker ps` - is `patherly_postgres` running? +2. Check `.env` - is DATABASE_URL correct (`patherly` not `decision_tree`)? +3. Check venv: is `(venv)` prefix showing? + +### Frontend compile errors +1. Check `frontend/src/lib/utils.ts` exists (provides `cn()` function) +2. Run `npm install` to ensure dependencies are installed + +### Tests failing +1. Create test database: `docker exec -it patherly_postgres psql -U postgres -c "CREATE DATABASE patherly_test;"` +2. Install dev deps: `pip install -r requirements-dev.txt` +3. Ensure pytest-asyncio version: `pip install pytest-asyncio==0.24.0` + +### API 500 errors +1. Check server logs for datetime errors (timezone issue) +2. Ensure all datetimes use `datetime.now(timezone.utc)` + +--- + +## Quick Reference + +| What | Where | +|------|-------| +| API Docs | http://localhost:8000/api/docs | +| Current Status | [CURRENT-STATE.md](CURRENT-STATE.md) | +| Bug Fixes | [LESSONS-LEARNED.md](LESSONS-LEARNED.md) | +| Feature Specs | [04-FEATURE-SPECIFICATIONS.md](04-FEATURE-SPECIFICATIONS.md) | +| Phase 2.5 Spec | [PHASE-2.5-PERSONAL-BRANCHING.md](PHASE-2.5-PERSONAL-BRANCHING.md) | + +--- + +## Contact + +**Primary User:** Michael Chihlas +**Communication:** GitHub Issues / Direct chat diff --git a/backend/alembic/versions/005_add_tree_organization.py b/backend/alembic/versions/005_add_tree_organization.py new file mode 100644 index 00000000..6ca4046e --- /dev/null +++ b/backend/alembic/versions/005_add_tree_organization.py @@ -0,0 +1,193 @@ +"""Add tree organization: tags, categories, folders, team admin + +Revision ID: 005 +Revises: 004 +Create Date: 2026-02-01 + +""" +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 = '005' +down_revision: Union[str, None] = '004' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # =========================================== + # 1. Add is_team_admin to users + # =========================================== + op.add_column('users', sa.Column('is_team_admin', sa.Boolean(), nullable=False, server_default='false')) + + # =========================================== + # 2. Create tree_categories table + # =========================================== + op.create_table( + 'tree_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), + 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_tree_categories_slug_team') + ) + op.create_index('ix_tree_categories_team_id', 'tree_categories', ['team_id']) + op.create_index('ix_tree_categories_slug', 'tree_categories', ['slug']) + op.create_index('ix_tree_categories_display_order', 'tree_categories', ['display_order']) + + # =========================================== + # 3. Create tree_tags table + # =========================================== + op.create_table( + 'tree_tags', + sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text('gen_random_uuid()')), + sa.Column('name', sa.String(50), nullable=False), + sa.Column('slug', sa.String(50), nullable=False), + sa.Column('team_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('teams.id', ondelete='CASCADE'), nullable=True), + sa.Column('usage_count', sa.Integer(), nullable=False, server_default='0'), + 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.UniqueConstraint('slug', 'team_id', name='uq_tree_tags_slug_team') + ) + op.create_index('ix_tree_tags_slug', 'tree_tags', ['slug']) + op.create_index('ix_tree_tags_team_id', 'tree_tags', ['team_id']) + op.create_index('ix_tree_tags_usage_count', 'tree_tags', ['usage_count']) + + # =========================================== + # 4. Create tree_tag_assignments junction table + # =========================================== + op.create_table( + 'tree_tag_assignments', + sa.Column('tree_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('trees.id', ondelete='CASCADE'), primary_key=True), + sa.Column('tag_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('tree_tags.id', ondelete='CASCADE'), primary_key=True), + sa.Column('assigned_by', postgresql.UUID(as_uuid=True), sa.ForeignKey('users.id', ondelete='SET NULL'), nullable=True), + sa.Column('assigned_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()')) + ) + op.create_index('ix_tree_tag_assignments_tree_id', 'tree_tag_assignments', ['tree_id']) + op.create_index('ix_tree_tag_assignments_tag_id', 'tree_tag_assignments', ['tag_id']) + + # =========================================== + # 5. Create user_folders table + # =========================================== + op.create_table( + 'user_folders', + sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text('gen_random_uuid()')), + sa.Column('user_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False), + sa.Column('name', sa.String(100), nullable=False), + sa.Column('color', sa.String(7), nullable=False, server_default='#6366f1'), + sa.Column('icon', sa.String(50), nullable=False, server_default='folder'), + sa.Column('display_order', sa.Integer(), nullable=False, server_default='0'), + 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('user_id', 'name', name='uq_user_folders_user_name') + ) + op.create_index('ix_user_folders_user_id', 'user_folders', ['user_id']) + + # =========================================== + # 6. Create user_folder_trees junction table + # =========================================== + op.create_table( + 'user_folder_trees', + sa.Column('folder_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('user_folders.id', ondelete='CASCADE'), primary_key=True), + sa.Column('tree_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('trees.id', ondelete='CASCADE'), primary_key=True), + sa.Column('added_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()')), + sa.Column('display_order', sa.Integer(), nullable=False, server_default='0') + ) + op.create_index('ix_user_folder_trees_folder_id', 'user_folder_trees', ['folder_id']) + op.create_index('ix_user_folder_trees_tree_id', 'user_folder_trees', ['tree_id']) + + # =========================================== + # 7. Add category_id to trees table + # =========================================== + op.add_column('trees', sa.Column('category_id', postgresql.UUID(as_uuid=True), nullable=True)) + op.create_foreign_key('fk_trees_category_id', 'trees', 'tree_categories', ['category_id'], ['id'], ondelete='SET NULL') + op.create_index('ix_trees_category_id', 'trees', ['category_id']) + + # =========================================== + # 8. Seed default global categories + # =========================================== + op.execute(""" + INSERT INTO tree_categories (name, slug, description, display_order, team_id) VALUES + ('Help Desk', 'help-desk', 'Tier 1 support - password resets, basic troubleshooting', 1, NULL), + ('Desktop Support', 'desktop-support', 'Tier 2 support - workstation issues, software problems', 2, NULL), + ('Systems Administration', 'systems-administration', 'Tier 3 support - servers, Active Directory, infrastructure', 3, NULL), + ('Networking', 'networking', 'Network connectivity, VPN, firewall, DNS/DHCP', 4, NULL), + ('Security', 'security', 'Security incidents, access issues, compliance', 5, NULL), + ('Cloud & Microsoft 365', 'cloud-m365', 'Microsoft 365, Azure, cloud services', 6, NULL), + ('Backup & Recovery', 'backup-recovery', 'Backup systems, disaster recovery, data restoration', 7, NULL), + ('Printing', 'printing', 'Print servers, printers, drivers, spooler issues', 8, NULL) + """) + + # =========================================== + # 9. Migrate existing category strings to new system + # =========================================== + # First, create tags from existing categories + op.execute(""" + INSERT INTO tree_tags (name, slug, team_id, usage_count) + SELECT DISTINCT + category as name, + LOWER(REGEXP_REPLACE(REGEXP_REPLACE(category, '[^a-zA-Z0-9 ]', '', 'g'), ' +', '-', 'g')) as slug, + NULL::uuid as team_id, + COUNT(*) OVER (PARTITION BY category) as usage_count + FROM trees + WHERE category IS NOT NULL AND category != '' + ON CONFLICT (slug, team_id) DO NOTHING + """) + + # Assign tags to trees based on their old category + op.execute(""" + INSERT INTO tree_tag_assignments (tree_id, tag_id) + SELECT t.id as tree_id, tt.id as tag_id + FROM trees t + JOIN tree_tags tt ON tt.slug = LOWER(REGEXP_REPLACE(REGEXP_REPLACE(t.category, '[^a-zA-Z0-9 ]', '', 'g'), ' +', '-', 'g')) + AND tt.team_id IS NULL + WHERE t.category IS NOT NULL AND t.category != '' + ON CONFLICT DO NOTHING + """) + + +def downgrade() -> None: + # Drop foreign key and column from trees + op.drop_constraint('fk_trees_category_id', 'trees', type_='foreignkey') + op.drop_index('ix_trees_category_id', table_name='trees') + op.drop_column('trees', 'category_id') + + # Drop user_folder_trees + op.drop_index('ix_user_folder_trees_tree_id', table_name='user_folder_trees') + op.drop_index('ix_user_folder_trees_folder_id', table_name='user_folder_trees') + op.drop_table('user_folder_trees') + + # Drop user_folders + op.drop_index('ix_user_folders_user_id', table_name='user_folders') + op.drop_table('user_folders') + + # Drop tree_tag_assignments + op.drop_index('ix_tree_tag_assignments_tag_id', table_name='tree_tag_assignments') + op.drop_index('ix_tree_tag_assignments_tree_id', table_name='tree_tag_assignments') + op.drop_table('tree_tag_assignments') + + # Drop tree_tags + op.drop_index('ix_tree_tags_usage_count', table_name='tree_tags') + op.drop_index('ix_tree_tags_team_id', table_name='tree_tags') + op.drop_index('ix_tree_tags_slug', table_name='tree_tags') + op.drop_table('tree_tags') + + # Drop tree_categories + op.drop_index('ix_tree_categories_display_order', table_name='tree_categories') + op.drop_index('ix_tree_categories_slug', table_name='tree_categories') + op.drop_index('ix_tree_categories_team_id', table_name='tree_categories') + op.drop_table('tree_categories') + + # Remove is_team_admin from users + op.drop_column('users', 'is_team_admin') diff --git a/backend/alembic/versions/006_add_folder_hierarchy.py b/backend/alembic/versions/006_add_folder_hierarchy.py new file mode 100644 index 00000000..6a866d63 --- /dev/null +++ b/backend/alembic/versions/006_add_folder_hierarchy.py @@ -0,0 +1,54 @@ +"""Add folder hierarchy support + +Revision ID: 006 +Revises: 005 +Create Date: 2026-02-02 + +""" +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 = '006' +down_revision: Union[str, None] = '005' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Add parent_id column for folder hierarchy + op.add_column( + 'user_folders', + sa.Column( + 'parent_id', + postgresql.UUID(as_uuid=True), + sa.ForeignKey('user_folders.id', ondelete='CASCADE'), + nullable=True + ) + ) + op.create_index('ix_user_folders_parent_id', 'user_folders', ['parent_id']) + + # Update unique constraint to allow same name in different parent folders + # Old constraint: (user_id, name) must be unique + # New constraint: (user_id, name, parent_id) must be unique + # This allows folders with same name under different parents + op.drop_constraint('uq_user_folders_user_name', 'user_folders', type_='unique') + op.create_unique_constraint( + 'uq_user_folders_user_name_parent', + 'user_folders', + ['user_id', 'name', 'parent_id'] + ) + + +def downgrade() -> None: + # Restore original unique constraint + op.drop_constraint('uq_user_folders_user_name_parent', 'user_folders', type_='unique') + op.create_unique_constraint('uq_user_folders_user_name', 'user_folders', ['user_id', 'name']) + + # Remove parent_id column and index + op.drop_index('ix_user_folders_parent_id', table_name='user_folders') + op.drop_column('user_folders', 'parent_id') diff --git a/backend/app/api/endpoints/categories.py b/backend/app/api/endpoints/categories.py new file mode 100644 index 00000000..c63d2db5 --- /dev/null +++ b/backend/app/api/endpoints/categories.py @@ -0,0 +1,314 @@ +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_ +import re + +from app.core.database import get_db +from app.models.category import TreeCategory +from app.models.tree import Tree +from app.models.user import User +from app.schemas.category import CategoryCreate, CategoryUpdate, CategoryResponse, CategoryListResponse +from app.api.deps import get_current_user + +router = APIRouter(prefix="/categories", tags=["categories"]) + + +def slugify(name: str) -> str: + """Convert a name to a URL-safe slug.""" + slug = re.sub(r'[^a-zA-Z0-9 ]', '', name.lower()) + slug = re.sub(r' +', '-', slug.strip()) + return slug + + +def can_manage_category(user: User, category: TreeCategory) -> bool: + """Check if user can manage (edit/delete) a 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_category(user: User, team_id: Optional[UUID]) -> bool: + """Check if user can create a 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[CategoryListResponse]) +async def list_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 categories visible to the user. + + Returns global categories plus team-specific categories for the user's team. + """ + # Build query for accessible categories + query = select(TreeCategory) + + # Filter by active status + if not include_inactive: + query = query.where(TreeCategory.is_active == True) + + # Filter by visibility: global OR user's team + if team_only and current_user.team_id: + query = query.where(TreeCategory.team_id == current_user.team_id) + elif current_user.team_id: + query = query.where( + or_( + TreeCategory.team_id.is_(None), # Global + TreeCategory.team_id == current_user.team_id # User's team + ) + ) + else: + # User has no team, only show global categories + query = query.where(TreeCategory.team_id.is_(None)) + + query = query.order_by(TreeCategory.display_order, TreeCategory.name) + + result = await db.execute(query) + categories = result.scalars().all() + + # Get tree counts for each category + response = [] + for cat in categories: + # Count trees in this category + count_query = select(func.count(Tree.id)).where( + Tree.category_id == cat.id, + Tree.is_active == True + ) + count_result = await db.execute(count_query) + tree_count = count_result.scalar() or 0 + + response.append(CategoryListResponse( + 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, + tree_count=tree_count + )) + + return response + + +@router.get("/{category_id}", response_model=CategoryResponse) +async def get_category( + category_id: UUID, + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_user)] +): + """Get a specific category by ID.""" + result = await db.execute(select(TreeCategory).where(TreeCategory.id == category_id)) + category = result.scalar_one_or_none() + + if not category: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="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 category" + ) + + # Get tree count + count_query = select(func.count(Tree.id)).where( + Tree.category_id == category.id, + Tree.is_active == True + ) + count_result = await db.execute(count_query) + tree_count = count_result.scalar() or 0 + + return CategoryResponse( + 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, + tree_count=tree_count + ) + + +@router.post("", response_model=CategoryResponse, status_code=status.HTTP_201_CREATED) +async def create_category( + category_data: CategoryCreate, + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_user)] +): + """Create a new category. + + - Global admins can create global categories (team_id=None) + - Team admins can create team-specific categories for their team + """ + if not can_create_category(current_user, category_data.team_id): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You don't have permission to create this category" + ) + + # Generate slug + slug = slugify(category_data.name) + + # Check for duplicate slug within same scope (global or team) + existing_query = select(TreeCategory).where( + TreeCategory.slug == slug, + TreeCategory.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 category with slug '{slug}' already exists" + ) + + # Get next display order + order_query = select(func.max(TreeCategory.display_order)).where( + TreeCategory.team_id == category_data.team_id + ) + order_result = await db.execute(order_query) + max_order = order_result.scalar() or 0 + + new_category = TreeCategory( + 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 CategoryResponse( + 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, + tree_count=0 + ) + + +@router.put("/{category_id}", response_model=CategoryResponse) +async def update_category( + category_id: UUID, + category_data: CategoryUpdate, + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_user)] +): + """Update a category.""" + result = await db.execute(select(TreeCategory).where(TreeCategory.id == category_id)) + category = result.scalar_one_or_none() + + if not category: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Category not found" + ) + + if not can_manage_category(current_user, category): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You don't have permission to update this 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(TreeCategory).where( + TreeCategory.slug == new_slug, + TreeCategory.team_id == category.team_id, + TreeCategory.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 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) + + # Get tree count + count_query = select(func.count(Tree.id)).where( + Tree.category_id == category.id, + Tree.is_active == True + ) + count_result = await db.execute(count_query) + tree_count = count_result.scalar() or 0 + + return CategoryResponse( + 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, + tree_count=tree_count + ) + + +@router.delete("/{category_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_category( + category_id: UUID, + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_user)] +): + """Soft delete (archive) a category.""" + result = await db.execute(select(TreeCategory).where(TreeCategory.id == category_id)) + category = result.scalar_one_or_none() + + if not category: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Category not found" + ) + + if not can_manage_category(current_user, category): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You don't have permission to delete this category" + ) + + category.is_active = False + await db.commit() + return None diff --git a/backend/app/api/endpoints/folders.py b/backend/app/api/endpoints/folders.py new file mode 100644 index 00000000..f2358eb8 --- /dev/null +++ b/backend/app/api/endpoints/folders.py @@ -0,0 +1,549 @@ +from typing import Annotated +from uuid import UUID +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func +from sqlalchemy.orm import selectinload + +from app.core.database import get_db +from app.models.folder import UserFolder, user_folder_trees +from app.models.tree import Tree +from app.models.user import User +from app.schemas.folder import ( + FolderCreate, + FolderUpdate, + FolderResponse, + FolderListResponse, + FolderReorderRequest, + FolderTreeRequest +) +from app.api.deps import get_current_user + +router = APIRouter(prefix="/folders", tags=["folders"]) + +# Maximum nesting depth for folders (root -> child -> grandchild = 3 levels) +MAX_FOLDER_DEPTH = 3 + + +async def get_folder_depth(db: AsyncSession, folder_id: UUID, current_depth: int = 1) -> int: + """Calculate the depth of a folder in the hierarchy. + + A root folder has depth 1, its child has depth 2, etc. + """ + result = await db.execute( + select(UserFolder.parent_id).where(UserFolder.id == folder_id) + ) + parent_id = result.scalar_one_or_none() + + if parent_id is None: + return current_depth + return await get_folder_depth(db, parent_id, current_depth + 1) + + +async def is_descendant(db: AsyncSession, potential_descendant_id: UUID, ancestor_id: UUID) -> bool: + """Check if potential_descendant_id is a descendant of ancestor_id. + + Used to prevent cycles when moving folders. + """ + current_id = potential_descendant_id + visited = set() + + while current_id: + if current_id in visited: + return False # Cycle detected, shouldn't happen but be safe + if current_id == ancestor_id: + return True + visited.add(current_id) + + result = await db.execute( + select(UserFolder.parent_id).where(UserFolder.id == current_id) + ) + current_id = result.scalar_one_or_none() + + return False + + +def can_access_tree(user: User, tree: Tree) -> bool: + """Check if user can access a tree (to add to folder). + + User can access tree if: + - Tree is public + - User is the author + - Tree belongs to user's team + - User is a global admin + """ + if tree.is_public: + return True + if user.id == tree.author_id: + return True + if tree.team_id == user.team_id and user.team_id is not None: + return True + if user.role == "admin": + return True + return False + + +@router.get("", response_model=list[FolderListResponse]) +async def list_folders( + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_user)] +): + """List all folders for the current user. + + Returns folders ordered by display_order. + """ + query = ( + select(UserFolder) + .options(selectinload(UserFolder.trees)) + .where(UserFolder.user_id == current_user.id) + .order_by(UserFolder.display_order, UserFolder.name) + ) + + result = await db.execute(query) + folders = result.scalars().all() + + return [ + FolderListResponse( + id=folder.id, + name=folder.name, + color=folder.color, + icon=folder.icon, + parent_id=folder.parent_id, + display_order=folder.display_order, + tree_count=folder.tree_count + ) + for folder in folders + ] + + +@router.get("/{folder_id}", response_model=FolderResponse) +async def get_folder( + folder_id: UUID, + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_user)] +): + """Get a specific folder by ID.""" + result = await db.execute( + select(UserFolder) + .options(selectinload(UserFolder.trees)) + .where(UserFolder.id == folder_id) + ) + folder = result.scalar_one_or_none() + + if not folder: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Folder not found" + ) + + # Folders are private to their owner + if folder.user_id != current_user.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You don't have access to this folder" + ) + + return FolderResponse( + id=folder.id, + name=folder.name, + color=folder.color, + icon=folder.icon, + parent_id=folder.parent_id, + display_order=folder.display_order, + tree_count=folder.tree_count, + created_at=folder.created_at, + updated_at=folder.updated_at + ) + + +@router.post("", response_model=FolderResponse, status_code=status.HTTP_201_CREATED) +async def create_folder( + folder_data: FolderCreate, + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_user)] +): + """Create a new folder for the current user. + + Supports creating subfolders by specifying parent_id. + Maximum nesting depth is 3 levels. + """ + # Validate parent folder if specified + if folder_data.parent_id: + parent_result = await db.execute( + select(UserFolder).where( + UserFolder.id == folder_data.parent_id, + UserFolder.user_id == current_user.id + ) + ) + parent = parent_result.scalar_one_or_none() + if not parent: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Parent folder not found" + ) + + # Check nesting depth (parent depth + 1 for new folder) + parent_depth = await get_folder_depth(db, folder_data.parent_id) + if parent_depth >= MAX_FOLDER_DEPTH: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Maximum folder nesting depth ({MAX_FOLDER_DEPTH} levels) exceeded" + ) + + # Check for duplicate name within same parent + existing_query = select(UserFolder).where( + UserFolder.user_id == current_user.id, + UserFolder.name == folder_data.name, + UserFolder.parent_id == folder_data.parent_id + ) + existing = await db.execute(existing_query) + if existing.scalar_one_or_none(): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"A folder named '{folder_data.name}' already exists at this level" + ) + + # Get next display order for this parent level + order_query = select(func.max(UserFolder.display_order)).where( + UserFolder.user_id == current_user.id, + UserFolder.parent_id == folder_data.parent_id + ) + order_result = await db.execute(order_query) + max_order = order_result.scalar() or 0 + + new_folder = UserFolder( + user_id=current_user.id, + name=folder_data.name, + color=folder_data.color, + icon=folder_data.icon, + parent_id=folder_data.parent_id, + display_order=max_order + 1 + ) + db.add(new_folder) + await db.commit() + await db.refresh(new_folder) + + return FolderResponse( + id=new_folder.id, + name=new_folder.name, + color=new_folder.color, + icon=new_folder.icon, + parent_id=new_folder.parent_id, + display_order=new_folder.display_order, + tree_count=0, + created_at=new_folder.created_at, + updated_at=new_folder.updated_at + ) + + +@router.put("/{folder_id}", response_model=FolderResponse) +async def update_folder( + folder_id: UUID, + folder_data: FolderUpdate, + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_user)] +): + """Update a folder. + + Supports moving folders by changing parent_id. + Validates to prevent cycles and excessive nesting. + """ + result = await db.execute( + select(UserFolder) + .options(selectinload(UserFolder.trees)) + .where(UserFolder.id == folder_id) + ) + folder = result.scalar_one_or_none() + + if not folder: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Folder not found" + ) + + if folder.user_id != current_user.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You don't have permission to update this folder" + ) + + update_data = folder_data.model_dump(exclude_unset=True) + + # Handle parent_id change (moving folder) + if "parent_id" in update_data: + new_parent_id = update_data["parent_id"] + + # Only validate if actually changing parent + if new_parent_id != folder.parent_id: + if new_parent_id is not None: + # Can't be its own parent + if new_parent_id == folder_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="A folder cannot be its own parent" + ) + + # Check parent exists and belongs to user + parent_result = await db.execute( + select(UserFolder).where( + UserFolder.id == new_parent_id, + UserFolder.user_id == current_user.id + ) + ) + if not parent_result.scalar_one_or_none(): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Parent folder not found" + ) + + # Prevent cycles - new parent can't be a descendant of this folder + if await is_descendant(db, new_parent_id, folder_id): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Cannot move folder into its own descendant" + ) + + # Check nesting depth after move + parent_depth = await get_folder_depth(db, new_parent_id) + if parent_depth >= MAX_FOLDER_DEPTH: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Maximum folder nesting depth ({MAX_FOLDER_DEPTH} levels) exceeded" + ) + + # Check for duplicate name if changing name or parent + new_name = update_data.get("name", folder.name) + new_parent_id = update_data.get("parent_id", folder.parent_id) + + if new_name != folder.name or ("parent_id" in update_data and new_parent_id != folder.parent_id): + existing_query = select(UserFolder).where( + UserFolder.user_id == current_user.id, + UserFolder.name == new_name, + UserFolder.parent_id == new_parent_id, + UserFolder.id != folder_id + ) + existing = await db.execute(existing_query) + if existing.scalar_one_or_none(): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"A folder named '{new_name}' already exists at this level" + ) + + for field, value in update_data.items(): + setattr(folder, field, value) + + await db.commit() + await db.refresh(folder) + + return FolderResponse( + id=folder.id, + name=folder.name, + color=folder.color, + icon=folder.icon, + parent_id=folder.parent_id, + display_order=folder.display_order, + tree_count=folder.tree_count, + created_at=folder.created_at, + updated_at=folder.updated_at + ) + + +@router.delete("/{folder_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_folder( + folder_id: UUID, + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_user)] +): + """Delete a folder. + + This only removes the folder, not the trees in it. + """ + result = await db.execute( + select(UserFolder).where(UserFolder.id == folder_id) + ) + folder = result.scalar_one_or_none() + + if not folder: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Folder not found" + ) + + if folder.user_id != current_user.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You don't have permission to delete this folder" + ) + + await db.delete(folder) + await db.commit() + return None + + +@router.post("/reorder", status_code=status.HTTP_204_NO_CONTENT) +async def reorder_folders( + reorder_data: FolderReorderRequest, + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_user)] +): + """Reorder folders by providing folder IDs in desired order.""" + # Get all user's folders + result = await db.execute( + select(UserFolder).where(UserFolder.user_id == current_user.id) + ) + folders = {f.id: f for f in result.scalars().all()} + + # Verify all provided folder IDs belong to user + for folder_id in reorder_data.folder_ids: + if folder_id not in folders: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Folder {folder_id} not found or doesn't belong to you" + ) + + # Update display orders + for order, folder_id in enumerate(reorder_data.folder_ids): + folders[folder_id].display_order = order + + await db.commit() + return None + + +@router.post("/{folder_id}/trees", status_code=status.HTTP_201_CREATED) +async def add_tree_to_folder( + folder_id: UUID, + request: FolderTreeRequest, + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_user)] +): + """Add a tree to a folder.""" + # Get folder with trees + folder_result = await db.execute( + select(UserFolder) + .options(selectinload(UserFolder.trees)) + .where(UserFolder.id == folder_id) + ) + folder = folder_result.scalar_one_or_none() + + if not folder: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Folder not found" + ) + + if folder.user_id != current_user.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You don't have permission to modify this folder" + ) + + # Get tree + tree_result = await db.execute( + select(Tree).where(Tree.id == request.tree_id) + ) + tree = tree_result.scalar_one_or_none() + + if not tree: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Tree not found" + ) + + if not can_access_tree(current_user, tree): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You don't have access to this tree" + ) + + # Check if already in folder + if tree in folder.trees: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Tree is already in this folder" + ) + + # Add tree to folder + folder.trees.append(tree) + await db.commit() + + return {"message": "Tree added to folder"} + + +@router.delete("/{folder_id}/trees/{tree_id}", status_code=status.HTTP_204_NO_CONTENT) +async def remove_tree_from_folder( + folder_id: UUID, + tree_id: UUID, + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_user)] +): + """Remove a tree from a folder.""" + # Get folder with trees + folder_result = await db.execute( + select(UserFolder) + .options(selectinload(UserFolder.trees)) + .where(UserFolder.id == folder_id) + ) + folder = folder_result.scalar_one_or_none() + + if not folder: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Folder not found" + ) + + if folder.user_id != current_user.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You don't have permission to modify this folder" + ) + + # Find tree in folder + tree_to_remove = None + for tree in folder.trees: + if tree.id == tree_id: + tree_to_remove = tree + break + + if not tree_to_remove: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Tree not found in this folder" + ) + + folder.trees.remove(tree_to_remove) + await db.commit() + return None + + +@router.get("/{folder_id}/trees", response_model=list[UUID]) +async def get_folder_tree_ids( + folder_id: UUID, + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_user)] +): + """Get all tree IDs in a folder. + + Returns just the IDs for lightweight checking. + Use the trees endpoint with folder_id filter for full tree data. + """ + # Get folder with trees + folder_result = await db.execute( + select(UserFolder) + .options(selectinload(UserFolder.trees)) + .where(UserFolder.id == folder_id) + ) + folder = folder_result.scalar_one_or_none() + + if not folder: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Folder not found" + ) + + if folder.user_id != current_user.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You don't have access to this folder" + ) + + return [tree.id for tree in folder.trees] diff --git a/backend/app/api/endpoints/tags.py b/backend/app/api/endpoints/tags.py new file mode 100644 index 00000000..27492372 --- /dev/null +++ b/backend/app/api/endpoints/tags.py @@ -0,0 +1,437 @@ +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_, delete +from sqlalchemy.orm import selectinload + +from app.core.database import get_db +from app.models.tag import TreeTag, tree_tag_assignments +from app.models.tree import Tree +from app.models.user import User +from app.schemas.tag import TagCreate, TagResponse, TagListResponse, TagAssignment +from app.api.deps import get_current_user + +router = APIRouter(prefix="/tags", tags=["tags"]) + + +def can_manage_tree_tags(user: User, tree: Tree) -> bool: + """Check if user can manage tags on a tree. + + Allowed: + - Tree author + - Global admins + - Team admins for their team's trees + """ + if user.id == tree.author_id: + return True + if user.role == "admin": + return True + if user.is_team_admin and tree.team_id == user.team_id: + return True + return False + + +def can_create_tag(user: User, team_id: Optional[UUID]) -> bool: + """Check if user can create a tag for the given scope. + + - Global admins can create global tags (team_id=None) + - Team admins and global admins can create team-specific tags + - Regular users can create team tags for their own team + """ + if user.role == "admin": + return True + # For team-specific tags, user must belong to that team + if team_id is not None and team_id == user.team_id: + return True + return False + + +@router.get("", response_model=list[TagListResponse]) +async def list_tags( + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_user)], + include_team: bool = Query(True, description="Include team-specific tags") +): + """List tags visible to the user. + + Returns global tags plus team-specific tags for the user's team. + Tags are ordered by usage count (most used first). + """ + query = select(TreeTag) + + # Filter by visibility: global OR user's team + if include_team and current_user.team_id: + query = query.where( + or_( + TreeTag.team_id.is_(None), # Global + TreeTag.team_id == current_user.team_id # User's team + ) + ) + else: + # Only show global tags + query = query.where(TreeTag.team_id.is_(None)) + + query = query.order_by(TreeTag.usage_count.desc(), TreeTag.name) + + result = await db.execute(query) + tags = result.scalars().all() + + return [TagListResponse.model_validate(tag) for tag in tags] + + +@router.get("/search", response_model=list[TagListResponse]) +async def search_tags( + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_user)], + q: str = Query(..., min_length=1, description="Search query"), + limit: int = Query(10, ge=1, le=50), + include_team: bool = Query(True, description="Include team-specific tags") +): + """Search/autocomplete tags. + + Searches tag names for the query string. + Returns matching tags ordered by usage count. + """ + query = select(TreeTag).where( + TreeTag.name.ilike(f"%{q}%") + ) + + # Filter by visibility + if include_team and current_user.team_id: + query = query.where( + or_( + TreeTag.team_id.is_(None), + TreeTag.team_id == current_user.team_id + ) + ) + else: + query = query.where(TreeTag.team_id.is_(None)) + + query = query.order_by(TreeTag.usage_count.desc(), TreeTag.name).limit(limit) + + result = await db.execute(query) + tags = result.scalars().all() + + return [TagListResponse.model_validate(tag) for tag in tags] + + +@router.get("/{tag_id}", response_model=TagResponse) +async def get_tag( + tag_id: UUID, + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_user)] +): + """Get a specific tag by ID.""" + result = await db.execute(select(TreeTag).where(TreeTag.id == tag_id)) + tag = result.scalar_one_or_none() + + if not tag: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Tag not found" + ) + + # Check access: global tags visible to all, team tags only to team members + if tag.team_id and tag.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 tag" + ) + + return TagResponse.model_validate(tag) + + +@router.post("", response_model=TagResponse, status_code=status.HTTP_201_CREATED) +async def create_tag( + tag_data: TagCreate, + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_user)] +): + """Create a new tag. + + - Global admins can create global tags (team_id=None) + - Team members can create team-specific tags for their team + """ + if not can_create_tag(current_user, tag_data.team_id): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You don't have permission to create this tag" + ) + + # Generate slug + slug = TreeTag.slugify(tag_data.name) + + # Check for duplicate slug within same scope (global or team) + existing_query = select(TreeTag).where( + TreeTag.slug == slug, + TreeTag.team_id == tag_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 tag with slug '{slug}' already exists" + ) + + new_tag = TreeTag( + name=tag_data.name, + slug=slug, + team_id=tag_data.team_id, + created_by=current_user.id + ) + db.add(new_tag) + await db.commit() + await db.refresh(new_tag) + + return TagResponse.model_validate(new_tag) + + +@router.post("/trees/{tree_id}", response_model=list[TagListResponse]) +async def add_tags_to_tree( + tree_id: UUID, + tag_data: TagAssignment, + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_user)] +): + """Add tags to a tree. + + If a tag doesn't exist, it will be created as a team tag (or global for admins). + Returns the updated list of tags on the tree. + """ + # Get tree with tags + result = await db.execute( + select(Tree) + .options(selectinload(Tree.tags)) + .where(Tree.id == tree_id) + ) + tree = result.scalar_one_or_none() + + if not tree: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Tree not found" + ) + + if not can_manage_tree_tags(current_user, tree): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You don't have permission to manage tags on this tree" + ) + + # Process each tag name + existing_tag_slugs = {tag.slug for tag in tree.tags} + + for tag_name in tag_data.tags: + slug = TreeTag.slugify(tag_name) + + # Skip if already assigned + if slug in existing_tag_slugs: + continue + + # Try to find existing tag + # Determine scope: use tree's team, or global for admin-owned trees + tag_team_id = tree.team_id or (current_user.team_id if current_user.role != "admin" else None) + + tag_query = select(TreeTag).where( + TreeTag.slug == slug, + or_( + TreeTag.team_id.is_(None), # Global tag + TreeTag.team_id == tag_team_id # Team tag + ) + ) + tag_result = await db.execute(tag_query) + tag = tag_result.scalar_one_or_none() + + if not tag: + # Create new tag - prefer team scope unless admin creating on public tree + new_team_id = tag_team_id + if not can_create_tag(current_user, new_team_id): + # Fall back to user's team if they can't create in tree's scope + new_team_id = current_user.team_id + + tag = TreeTag( + name=tag_name, + slug=slug, + team_id=new_team_id, + created_by=current_user.id + ) + db.add(tag) + await db.flush() # Get the ID + + # Add tag to tree + tree.tags.append(tag) + tag.usage_count += 1 + existing_tag_slugs.add(slug) + + await db.commit() + await db.refresh(tree) + + # Return updated tags + return [TagListResponse.model_validate(tag) for tag in tree.tags] + + +@router.delete("/trees/{tree_id}/{tag_slug}", status_code=status.HTTP_204_NO_CONTENT) +async def remove_tag_from_tree( + tree_id: UUID, + tag_slug: str, + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_user)] +): + """Remove a tag from a tree.""" + # Get tree with tags + result = await db.execute( + select(Tree) + .options(selectinload(Tree.tags)) + .where(Tree.id == tree_id) + ) + tree = result.scalar_one_or_none() + + if not tree: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Tree not found" + ) + + if not can_manage_tree_tags(current_user, tree): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You don't have permission to manage tags on this tree" + ) + + # Find the tag to remove + tag_to_remove = None + for tag in tree.tags: + if tag.slug == tag_slug: + tag_to_remove = tag + break + + if not tag_to_remove: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Tag not found on this tree" + ) + + # Remove the tag from tree + tree.tags.remove(tag_to_remove) + tag_to_remove.usage_count = max(0, tag_to_remove.usage_count - 1) + + await db.commit() + return None + + +@router.put("/trees/{tree_id}", response_model=list[TagListResponse]) +async def replace_tree_tags( + tree_id: UUID, + tag_data: TagAssignment, + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_user)] +): + """Replace all tags on a tree. + + Removes all existing tags and assigns the new list. + If a tag doesn't exist, it will be created. + Returns the updated list of tags on the tree. + """ + # Get tree with tags + result = await db.execute( + select(Tree) + .options(selectinload(Tree.tags)) + .where(Tree.id == tree_id) + ) + tree = result.scalar_one_or_none() + + if not tree: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Tree not found" + ) + + if not can_manage_tree_tags(current_user, tree): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You don't have permission to manage tags on this tree" + ) + + # Decrement usage count for all existing tags + for tag in tree.tags: + tag.usage_count = max(0, tag.usage_count - 1) + + # Clear all existing tags + tree.tags.clear() + + # Add new tags + tag_team_id = tree.team_id or (current_user.team_id if current_user.role != "admin" else None) + + for tag_name in tag_data.tags: + slug = TreeTag.slugify(tag_name) + + # Try to find existing tag + tag_query = select(TreeTag).where( + TreeTag.slug == slug, + or_( + TreeTag.team_id.is_(None), + TreeTag.team_id == tag_team_id + ) + ) + tag_result = await db.execute(tag_query) + tag = tag_result.scalar_one_or_none() + + if not tag: + # Create new tag + new_team_id = tag_team_id + if not can_create_tag(current_user, new_team_id): + new_team_id = current_user.team_id + + tag = TreeTag( + name=tag_name, + slug=slug, + team_id=new_team_id, + created_by=current_user.id + ) + db.add(tag) + await db.flush() + + # Add tag to tree + if tag not in tree.tags: # Avoid duplicates from input + tree.tags.append(tag) + tag.usage_count += 1 + + await db.commit() + await db.refresh(tree) + + return [TagListResponse.model_validate(tag) for tag in tree.tags] + + +@router.get("/trees/{tree_id}", response_model=list[TagListResponse]) +async def get_tree_tags( + tree_id: UUID, + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_user)] +): + """Get all tags assigned to a tree.""" + # Get tree with tags + result = await db.execute( + select(Tree) + .options(selectinload(Tree.tags)) + .where(Tree.id == tree_id) + ) + tree = result.scalar_one_or_none() + + if not tree: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Tree not found" + ) + + # Check if user can view the tree + if not tree.is_public: + if tree.author_id != current_user.id: + if tree.team_id != current_user.team_id: + if current_user.role != "admin": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You don't have access to this tree" + ) + + return [TagListResponse.model_validate(tag) for tag in tree.tags] diff --git a/backend/app/api/endpoints/trees.py b/backend/app/api/endpoints/trees.py index 2177abea..d9e20691 100644 --- a/backend/app/api/endpoints/trees.py +++ b/backend/app/api/endpoints/trees.py @@ -3,53 +3,173 @@ 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 sqlalchemy.orm import selectinload from app.core.database import get_db from app.models.tree import Tree from app.models.user import User -from app.schemas.tree import TreeCreate, TreeUpdate, TreeResponse, TreeListResponse +from app.models.category import TreeCategory +from app.models.tag import TreeTag +from app.models.folder import UserFolder +from app.schemas.tree import TreeCreate, TreeUpdate, TreeResponse, TreeListResponse, CategoryInfo from app.api.deps import get_current_user, require_engineer_or_admin, require_admin router = APIRouter(prefix="/trees", tags=["trees"]) +def build_tree_access_filter(current_user: User): + """Build the access filter for trees based on user permissions. + + Returns trees that are: + - Default/system trees (visible to all) + - Public trees + - User's own trees + - Trees from user's team + """ + return or_( + Tree.is_default == True, + Tree.is_public == True, + Tree.author_id == current_user.id, + Tree.team_id == current_user.team_id if current_user.team_id else False + ) + + +def build_tree_response(tree: Tree) -> TreeListResponse: + """Build TreeListResponse with category_info and tags.""" + category_info = None + if tree.category_rel: + category_info = CategoryInfo( + id=tree.category_rel.id, + name=tree.category_rel.name, + slug=tree.category_rel.slug + ) + + return TreeListResponse( + id=tree.id, + name=tree.name, + description=tree.description, + category=tree.category, + category_id=tree.category_id, + category_info=category_info, + tags=tree.tag_names, + author_id=tree.author_id, + is_active=tree.is_active, + is_public=tree.is_public, + is_default=tree.is_default, + version=tree.version, + usage_count=tree.usage_count, + created_at=tree.created_at, + updated_at=tree.updated_at + ) + + +def build_full_tree_response(tree: Tree) -> TreeResponse: + """Build TreeResponse with all details including category_info and tags.""" + category_info = None + if tree.category_rel: + category_info = CategoryInfo( + id=tree.category_rel.id, + name=tree.category_rel.name, + slug=tree.category_rel.slug + ) + + return TreeResponse( + id=tree.id, + name=tree.name, + description=tree.description, + category=tree.category, + category_id=tree.category_id, + category_info=category_info, + tags=tree.tag_names, + tree_structure=tree.tree_structure, + author_id=tree.author_id, + team_id=tree.team_id, + is_active=tree.is_active, + is_public=tree.is_public, + is_default=tree.is_default, + version=tree.version, + usage_count=tree.usage_count, + created_at=tree.created_at, + updated_at=tree.updated_at + ) + + @router.get("", response_model=list[TreeListResponse]) async def list_trees( db: Annotated[AsyncSession, Depends(get_db)], current_user: Annotated[User, Depends(get_current_user)], - category: Optional[str] = Query(None, description="Filter by category"), + category: Optional[str] = Query(None, description="Filter by legacy category string"), + category_id: Optional[UUID] = Query(None, description="Filter by category ID"), + tags: Optional[str] = Query(None, description="Comma-separated tag slugs to filter by"), + folder_id: Optional[UUID] = Query(None, description="Filter by folder ID (user's folders only)"), is_active: Optional[bool] = Query(None, description="Filter by active status"), + author_id: Optional[UUID] = Query(None, description="Filter by author ID"), + is_public: Optional[bool] = Query(None, description="Filter by public status"), skip: int = Query(0, ge=0), limit: int = Query(100, ge=1, le=100) ): - """List all trees with optional filters.""" - query = select(Tree) + """List all trees with optional filters. + + New filters: + - category_id: Filter by category (from tree_categories table) + - tags: Comma-separated tag slugs (e.g., "citrix,networking") + - folder_id: Show only trees in a specific folder + """ + query = select(Tree).options( + selectinload(Tree.category_rel), + selectinload(Tree.tags) + ) # Apply filters if category: query = query.where(Tree.category == category) + if category_id: + query = query.where(Tree.category_id == category_id) if is_active is not None: query = query.where(Tree.is_active == is_active) + else: + # Default to only showing active trees + query = query.where(Tree.is_active == True) + if author_id: + query = query.where(Tree.author_id == author_id) + if is_public is not None: + query = query.where(Tree.is_public == is_public) - # Only show trees user has access to: - # - Default/system trees (visible to all) - # - Public trees - # - User's own trees (public or private) - query = query.where( - Tree.is_active == True, - or_( - Tree.is_default == True, - Tree.is_public == True, - Tree.author_id == current_user.id + # Filter by tags (all specified tags must be present) + if tags: + tag_slugs = [t.strip() for t in tags.split(",") if t.strip()] + for tag_slug in tag_slugs: + query = query.where( + Tree.tags.any(TreeTag.slug == tag_slug) + ) + + # Filter by folder + if folder_id: + # Verify folder belongs to user + folder_result = await db.execute( + select(UserFolder).where( + UserFolder.id == folder_id, + UserFolder.user_id == current_user.id + ) ) - ) + folder = folder_result.scalar_one_or_none() + if not folder: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Folder not found" + ) + query = query.where(Tree.folders.any(UserFolder.id == folder_id)) + + # Apply access filter + query = query.where(build_tree_access_filter(current_user)) query = query.order_by(Tree.usage_count.desc(), Tree.updated_at.desc()) query = query.offset(skip).limit(limit) result = await db.execute(query) - trees = result.scalars().all() - return trees + trees = result.scalars().unique().all() + + return [build_tree_response(tree) for tree in trees] @router.get("/categories", response_model=list[str]) @@ -57,15 +177,15 @@ async def list_categories( db: Annotated[AsyncSession, Depends(get_db)], current_user: Annotated[User, Depends(get_current_user)] ): - """List all unique categories from trees the user can access.""" + """List all unique categories from trees the user can access. + + Note: This returns legacy string categories. For the new category system, + use the /categories endpoint. + """ query = select(Tree.category).where( Tree.category.isnot(None), Tree.is_active == True, - or_( - Tree.is_default == True, - Tree.is_public == True, - Tree.author_id == current_user.id - ) + build_tree_access_filter(current_user) ).distinct() result = await db.execute(query) categories = [row[0] for row in result.all() if row[0]] @@ -84,21 +204,21 @@ async def search_trees( search_vector = func.to_tsvector('english', func.coalesce(Tree.name, '') + ' ' + func.coalesce(Tree.description, '')) search_query = func.plainto_tsquery('english', q) - query = select(Tree).where( + query = select(Tree).options( + selectinload(Tree.category_rel), + selectinload(Tree.tags) + ).where( Tree.is_active == True, - or_( - Tree.is_default == True, - Tree.is_public == True, - Tree.author_id == current_user.id - ), + build_tree_access_filter(current_user), search_vector.op('@@')(search_query) ).order_by( func.ts_rank(search_vector, search_query).desc() ).limit(limit) result = await db.execute(query) - trees = result.scalars().all() - return trees + trees = result.scalars().unique().all() + + return [build_tree_response(tree) for tree in trees] @router.get("/{tree_id}", response_model=TreeResponse) @@ -108,7 +228,14 @@ async def get_tree( current_user: Annotated[User, Depends(get_current_user)] ): """Get a specific tree by ID.""" - result = await db.execute(select(Tree).where(Tree.id == tree_id)) + result = await db.execute( + select(Tree) + .options( + selectinload(Tree.category_rel), + selectinload(Tree.tags) + ) + .where(Tree.id == tree_id) + ) tree = result.scalar_one_or_none() if not tree: @@ -117,15 +244,21 @@ async def get_tree( detail="Tree not found" ) - # Check access: tree must be active AND (default OR public OR author) - can_access = tree.is_default or tree.is_public or tree.author_id == current_user.id + # Check access: tree must be active AND (default OR public OR author OR same team) + can_access = ( + tree.is_default or + tree.is_public or + tree.author_id == current_user.id or + (tree.team_id == current_user.team_id and current_user.team_id is not None) or + current_user.role == "admin" + ) if not tree.is_active or not can_access: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="You don't have access to this tree" ) - return tree + return build_full_tree_response(tree) @router.post("", response_model=TreeResponse, status_code=status.HTTP_201_CREATED) @@ -134,14 +267,38 @@ async def create_tree( db: Annotated[AsyncSession, Depends(get_db)], current_user: Annotated[User, Depends(require_engineer_or_admin)] ): - """Create a new tree (engineers and admins only).""" + """Create a new tree (engineers and admins only). + + Supports: + - category_id: Assign to a category from tree_categories + - tags: List of tag names to assign (creates new tags if needed) + """ # Only admins can create default/system trees is_default = tree_data.is_default and current_user.role == "admin" + # Verify category exists if provided + if tree_data.category_id: + cat_result = await db.execute( + select(TreeCategory).where(TreeCategory.id == tree_data.category_id) + ) + category = cat_result.scalar_one_or_none() + if not category: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Category not found" + ) + # Check category access + 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 category" + ) + new_tree = Tree( name=tree_data.name, description=tree_data.description, category=tree_data.category, + category_id=tree_data.category_id, tree_structure=tree_data.tree_structure, author_id=None if is_default else current_user.id, # Default trees have no author team_id=None if is_default else current_user.team_id, @@ -149,9 +306,67 @@ async def create_tree( is_default=is_default ) db.add(new_tree) + await db.flush() # Get the ID + + # Handle tags + if tree_data.tags: + tree_team_id = new_tree.team_id or (current_user.team_id if current_user.role != "admin" else None) + + # Collect tags to add + tags_to_add = [] + for tag_name in tree_data.tags: + slug = TreeTag.slugify(tag_name) + + # Try to find existing tag + tag_query = select(TreeTag).where( + TreeTag.slug == slug, + or_( + TreeTag.team_id.is_(None), + TreeTag.team_id == tree_team_id + ) + ) + tag_result = await db.execute(tag_query) + tag = tag_result.scalar_one_or_none() + + if not tag: + # Create new tag + tag = TreeTag( + name=tag_name, + slug=slug, + team_id=tree_team_id, + created_by=current_user.id + ) + db.add(tag) + await db.flush() + + tags_to_add.append(tag) + tag.usage_count += 1 + + # Use direct SQL insert for the junction table to avoid lazy load issues + from app.models.tag import tree_tag_assignments + for tag in tags_to_add: + await db.execute( + tree_tag_assignments.insert().values( + tree_id=new_tree.id, + tag_id=tag.id, + assigned_by=current_user.id + ) + ) + await db.commit() - await db.refresh(new_tree) - return new_tree + + # Reload with relationships + result = await db.execute( + select(Tree) + .options( + selectinload(Tree.category_rel), + selectinload(Tree.tags) + ) + .where(Tree.id == new_tree.id) + ) + tree = result.scalar_one() + + return build_full_tree_response(tree) @router.put("/{tree_id}", response_model=TreeResponse) @@ -161,8 +376,20 @@ async def update_tree( db: Annotated[AsyncSession, Depends(get_db)], current_user: Annotated[User, Depends(require_engineer_or_admin)] ): - """Update an existing tree (engineers and admins only).""" - result = await db.execute(select(Tree).where(Tree.id == tree_id)) + """Update an existing tree (engineers and admins only). + + Supports: + - category_id: Change category assignment + - tags: Replace all tags on the tree + """ + result = await db.execute( + select(Tree) + .options( + selectinload(Tree.category_rel), + selectinload(Tree.tags) + ) + .where(Tree.id == tree_id) + ) tree = result.scalar_one_or_none() if not tree: @@ -171,15 +398,40 @@ async def update_tree( detail="Tree not found" ) - # Check if user can edit: must be author or admin - if tree.author_id != current_user.id and current_user.role != "admin": + # Check if user can edit: must be author, team admin for team trees, or global admin + can_edit = ( + tree.author_id == current_user.id or + current_user.role == "admin" or + (current_user.is_team_admin and tree.team_id == current_user.team_id) + ) + if not can_edit: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="You can only edit your own trees" ) - # Update fields + # Extract tags for separate handling update_data = tree_data.model_dump(exclude_unset=True) + tags_data = update_data.pop("tags", None) + + # Verify new category if provided + if "category_id" in update_data and update_data["category_id"]: + cat_result = await db.execute( + select(TreeCategory).where(TreeCategory.id == update_data["category_id"]) + ) + category = cat_result.scalar_one_or_none() + if not category: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Category not found" + ) + 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 category" + ) + + # Update basic fields for field, value in update_data.items(): setattr(tree, field, value) @@ -187,9 +439,73 @@ async def update_tree( if "tree_structure" in update_data: tree.version += 1 + # Handle tags replacement + if tags_data is not None: + from app.models.tag import tree_tag_assignments + + # Decrement usage count for old tags (already eagerly loaded) + for tag in tree.tags: + tag.usage_count = max(0, tag.usage_count - 1) + + # Delete existing tag assignments using direct SQL + await db.execute( + tree_tag_assignments.delete().where( + tree_tag_assignments.c.tree_id == tree.id + ) + ) + + # Add new tags + tree_team_id = tree.team_id or (current_user.team_id if current_user.role != "admin" else None) + added_tag_ids = set() + + for tag_name in tags_data: + slug = TreeTag.slugify(tag_name) + + tag_query = select(TreeTag).where( + TreeTag.slug == slug, + or_( + TreeTag.team_id.is_(None), + TreeTag.team_id == tree_team_id + ) + ) + tag_result = await db.execute(tag_query) + tag = tag_result.scalar_one_or_none() + + if not tag: + tag = TreeTag( + name=tag_name, + slug=slug, + team_id=tree_team_id, + created_by=current_user.id + ) + db.add(tag) + await db.flush() + + if tag.id not in added_tag_ids: + await db.execute( + tree_tag_assignments.insert().values( + tree_id=tree.id, + tag_id=tag.id, + assigned_by=current_user.id + ) + ) + added_tag_ids.add(tag.id) + tag.usage_count += 1 + await db.commit() - await db.refresh(tree) - return tree + + # Reload with relationships + result = await db.execute( + select(Tree) + .options( + selectinload(Tree.category_rel), + selectinload(Tree.tags) + ) + .where(Tree.id == tree_id) + ) + tree = result.scalar_one() + + return build_full_tree_response(tree) @router.delete("/{tree_id}", status_code=status.HTTP_204_NO_CONTENT) diff --git a/backend/app/api/router.py b/backend/app/api/router.py index 7779bbbe..9031c707 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -1,5 +1,5 @@ from fastapi import APIRouter -from app.api.endpoints import auth, trees, sessions, invite +from app.api.endpoints import auth, trees, sessions, invite, categories, tags, folders api_router = APIRouter() @@ -7,3 +7,6 @@ api_router.include_router(auth.router) api_router.include_router(trees.router) api_router.include_router(sessions.router) api_router.include_router(invite.router) +api_router.include_router(categories.router) +api_router.include_router(tags.router) +api_router.include_router(folders.router) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 2b91354c..5285938f 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -4,5 +4,20 @@ from .tree import Tree from .session import Session from .attachment import Attachment from .invite_code import InviteCode +from .category import TreeCategory +from .tag import TreeTag, tree_tag_assignments +from .folder import UserFolder, user_folder_trees -__all__ = ["User", "Team", "Tree", "Session", "Attachment", "InviteCode"] +__all__ = [ + "User", + "Team", + "Tree", + "Session", + "Attachment", + "InviteCode", + "TreeCategory", + "TreeTag", + "tree_tag_assignments", + "UserFolder", + "user_folder_trees", +] diff --git a/backend/app/models/category.py b/backend/app/models/category.py new file mode 100644 index 00000000..f8e62488 --- /dev/null +++ b/backend/app/models/category.py @@ -0,0 +1,66 @@ +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.tree import Tree + from app.models.team import Team + from app.models.user import User + + +class TreeCategory(Base): + """Admin-managed categories for organizing trees. + + 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__ = "tree_categories" + __table_args__ = ( + UniqueConstraint('slug', 'team_id', name='uq_tree_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="categories") + creator: Mapped[Optional["User"]] = relationship("User", foreign_keys=[created_by]) + trees: Mapped[list["Tree"]] = relationship("Tree", back_populates="category_rel") + + @property + def is_global(self) -> bool: + """Returns True if this is a global category (not team-specific).""" + return self.team_id is None diff --git a/backend/app/models/folder.py b/backend/app/models/folder.py new file mode 100644 index 00000000..7edaeaef --- /dev/null +++ b/backend/app/models/folder.py @@ -0,0 +1,93 @@ +import uuid +from datetime import datetime, timezone +from typing import TYPE_CHECKING, Optional +from sqlalchemy import String, DateTime, ForeignKey, Integer, UniqueConstraint, Table, Column +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.tree import Tree + from app.models.user import User + + +# Junction table for folder-tree many-to-many relationship +user_folder_trees = Table( + 'user_folder_trees', + Base.metadata, + Column('folder_id', UUID(as_uuid=True), ForeignKey('user_folders.id', ondelete='CASCADE'), primary_key=True), + Column('tree_id', UUID(as_uuid=True), ForeignKey('trees.id', ondelete='CASCADE'), primary_key=True), + Column('added_at', DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc)), + Column('display_order', Integer, nullable=False, default=0) +) + + +class UserFolder(Base): + """User-specific folders for organizing trees. + + - Each folder belongs to a single user + - Trees can be in multiple folders (like labels/tags) + - Folders are purely organizational - they don't affect tree visibility or permissions + """ + __tablename__ = "user_folders" + __table_args__ = ( + # Allow same name under different parents + UniqueConstraint('user_id', 'name', 'parent_id', name='uq_user_folders_user_name_parent'), + ) + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + primary_key=True, + default=uuid.uuid4 + ) + user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + index=True + ) + name: Mapped[str] = mapped_column(String(100), nullable=False) + color: Mapped[str] = mapped_column(String(7), nullable=False, default="#6366f1") + icon: Mapped[str] = mapped_column(String(50), nullable=False, default="folder") + display_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + parent_id: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), + ForeignKey("user_folders.id", ondelete="CASCADE"), + nullable=True, + index=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 + user: Mapped["User"] = relationship("User", back_populates="folders") + trees: Mapped[list["Tree"]] = relationship( + "Tree", + secondary=user_folder_trees, + back_populates="folders" + ) + # Self-referential relationships for folder hierarchy + parent: Mapped[Optional["UserFolder"]] = relationship( + "UserFolder", + remote_side="UserFolder.id", + back_populates="children", + foreign_keys=[parent_id] + ) + children: Mapped[list["UserFolder"]] = relationship( + "UserFolder", + back_populates="parent", + cascade="all, delete-orphan", + foreign_keys="UserFolder.parent_id" + ) + + @property + def tree_count(self) -> int: + """Returns the number of trees in this folder.""" + return len(self.trees) if self.trees else 0 diff --git a/backend/app/models/tag.py b/backend/app/models/tag.py new file mode 100644 index 00000000..6ebb6b38 --- /dev/null +++ b/backend/app/models/tag.py @@ -0,0 +1,86 @@ +import uuid +from datetime import datetime, timezone +from typing import Optional, TYPE_CHECKING +from sqlalchemy import String, DateTime, ForeignKey, Integer, UniqueConstraint, Table, Column +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.tree import Tree + from app.models.team import Team + from app.models.user import User + + +# Junction table for tree-tag many-to-many relationship +tree_tag_assignments = Table( + 'tree_tag_assignments', + Base.metadata, + Column('tree_id', UUID(as_uuid=True), ForeignKey('trees.id', ondelete='CASCADE'), primary_key=True), + Column('tag_id', UUID(as_uuid=True), ForeignKey('tree_tags.id', ondelete='CASCADE'), primary_key=True), + Column('assigned_by', UUID(as_uuid=True), ForeignKey('users.id', ondelete='SET NULL'), nullable=True), + Column('assigned_at', DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc)) +) + + +class TreeTag(Base): + """Tags for categorizing and filtering trees. + + Tags can be: + - Global (team_id=NULL): Available to all users + - Team-specific (team_id set): Only visible to team members + + Tags are managed by tree authors and admins (global or team). + """ + __tablename__ = "tree_tags" + __table_args__ = ( + UniqueConstraint('slug', 'team_id', name='uq_tree_tags_slug_team'), + ) + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + primary_key=True, + default=uuid.uuid4 + ) + name: Mapped[str] = mapped_column(String(50), nullable=False) + slug: Mapped[str] = mapped_column(String(50), nullable=False, index=True) + team_id: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), + ForeignKey("teams.id", ondelete="CASCADE"), + nullable=True, + index=True + ) + usage_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, index=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) + ) + + # Relationships + team: Mapped[Optional["Team"]] = relationship("Team", back_populates="tags") + creator: Mapped[Optional["User"]] = relationship("User", foreign_keys=[created_by]) + trees: Mapped[list["Tree"]] = relationship( + "Tree", + secondary=tree_tag_assignments, + back_populates="tags" + ) + + @property + def is_global(self) -> bool: + """Returns True if this is a global tag (not team-specific).""" + return self.team_id is None + + @classmethod + def slugify(cls, name: str) -> str: + """Convert a tag name to a URL-safe slug.""" + import re + # 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 diff --git a/backend/app/models/team.py b/backend/app/models/team.py index 6db7820d..9f36534f 100644 --- a/backend/app/models/team.py +++ b/backend/app/models/team.py @@ -1,10 +1,17 @@ import uuid from datetime import datetime, timezone +from typing import TYPE_CHECKING from sqlalchemy import String, DateTime 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.user import User + from app.models.tree import Tree + from app.models.category import TreeCategory + from app.models.tag import TreeTag + class Team(Base): __tablename__ = "teams" @@ -23,3 +30,5 @@ class Team(Base): # Relationships users: Mapped[list["User"]] = relationship("User", back_populates="team") 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") diff --git a/backend/app/models/tree.py b/backend/app/models/tree.py index e5ef4b35..7551ccf7 100644 --- a/backend/app/models/tree.py +++ b/backend/app/models/tree.py @@ -1,11 +1,19 @@ import uuid from datetime import datetime, timezone -from typing import Optional, Any +from typing import Optional, Any, TYPE_CHECKING from sqlalchemy import String, Text, DateTime, ForeignKey, Boolean, Integer, Index from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.dialects.postgresql import UUID, JSONB from app.core.database import Base +if TYPE_CHECKING: + from app.models.user import User + from app.models.team import Team + from app.models.session import Session + from app.models.category import TreeCategory + from app.models.tag import TreeTag + from app.models.folder import UserFolder + class Tree(Base): __tablename__ = "trees" @@ -17,7 +25,16 @@ class Tree(Base): ) name: Mapped[str] = mapped_column(String(255), nullable=False) description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + # Legacy category field - kept for backward compatibility + # New code should use category_id instead category: Mapped[Optional[str]] = mapped_column(String(100), nullable=True, index=True) + # New category relationship + category_id: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), + ForeignKey("tree_categories.id", ondelete="SET NULL"), + nullable=True, + index=True + ) tree_structure: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False) author_id: Mapped[Optional[uuid.UUID]] = mapped_column( UUID(as_uuid=True), @@ -50,4 +67,22 @@ class Tree(Base): team: Mapped[Optional["Team"]] = relationship("Team", back_populates="trees") sessions: Mapped[list["Session"]] = relationship("Session", back_populates="tree") + # New organization relationships + category_rel: Mapped[Optional["TreeCategory"]] = relationship("TreeCategory", back_populates="trees") + tags: Mapped[list["TreeTag"]] = relationship( + "TreeTag", + secondary="tree_tag_assignments", + back_populates="trees" + ) + folders: Mapped[list["UserFolder"]] = relationship( + "UserFolder", + secondary="user_folder_trees", + back_populates="trees" + ) + # Full-text search index will be created in migration + + @property + def tag_names(self) -> list[str]: + """Returns list of tag names for this tree.""" + return [tag.name for tag in self.tags] if self.tags else [] diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 37b411fa..55541bb2 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -1,11 +1,17 @@ import uuid from datetime import datetime, timezone -from typing import Optional -from sqlalchemy import String, DateTime, ForeignKey +from typing import Optional, TYPE_CHECKING +from sqlalchemy import String, DateTime, ForeignKey, Boolean 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.tree import Tree + from app.models.session import Session + from app.models.folder import UserFolder + class User(Base): __tablename__ = "users" @@ -19,6 +25,7 @@ class User(Base): password_hash: Mapped[str] = mapped_column(String(255), nullable=False) name: Mapped[str] = mapped_column(String(255), nullable=False) role: Mapped[str] = mapped_column(String(50), nullable=False, default="engineer") + is_team_admin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) team_id: Mapped[Optional[uuid.UUID]] = mapped_column( UUID(as_uuid=True), ForeignKey("teams.id"), @@ -38,4 +45,15 @@ class User(Base): # Relationships team: Mapped[Optional["Team"]] = relationship("Team", back_populates="users") trees: Mapped[list["Tree"]] = relationship("Tree", back_populates="author") - sessions: Mapped[list["Session"]] = relationship("Session", back_populates="user") \ No newline at end of file + sessions: Mapped[list["Session"]] = relationship("Session", back_populates="user") + folders: Mapped[list["UserFolder"]] = relationship("UserFolder", back_populates="user") + + @property + def is_admin(self) -> bool: + """Returns True if user is a global (Patherly) admin.""" + return self.role == "admin" + + @property + def can_manage_team(self) -> bool: + """Returns True if user can manage their team (team admin or global admin).""" + return self.is_admin or (self.is_team_admin and self.team_id is not None) diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index e08aa5ec..e5fe8241 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -2,10 +2,23 @@ from .user import UserCreate, UserUpdate, UserResponse, UserLogin from .token import Token, TokenPayload from .tree import TreeCreate, TreeUpdate, TreeResponse, TreeListResponse from .session import SessionCreate, SessionUpdate, SessionResponse, SessionExport, DecisionRecord +from .category import CategoryCreate, CategoryUpdate, CategoryResponse, CategoryListResponse +from .tag import TagCreate, TagResponse, TagListResponse, TagAssignment +from .folder import FolderCreate, FolderUpdate, FolderResponse, FolderListResponse, FolderReorderRequest, FolderTreeRequest __all__ = [ + # User "UserCreate", "UserUpdate", "UserResponse", "UserLogin", + # Token "Token", "TokenPayload", + # Tree "TreeCreate", "TreeUpdate", "TreeResponse", "TreeListResponse", - "SessionCreate", "SessionUpdate", "SessionResponse", "SessionExport", "DecisionRecord" + # Session + "SessionCreate", "SessionUpdate", "SessionResponse", "SessionExport", "DecisionRecord", + # Category + "CategoryCreate", "CategoryUpdate", "CategoryResponse", "CategoryListResponse", + # Tag + "TagCreate", "TagResponse", "TagListResponse", "TagAssignment", + # Folder + "FolderCreate", "FolderUpdate", "FolderResponse", "FolderListResponse", "FolderReorderRequest", "FolderTreeRequest", ] diff --git a/backend/app/schemas/category.py b/backend/app/schemas/category.py new file mode 100644 index 00000000..2cca0694 --- /dev/null +++ b/backend/app/schemas/category.py @@ -0,0 +1,58 @@ +from datetime import datetime +from typing import Optional +from uuid import UUID +from pydantic import BaseModel, Field +import re + + +def slugify(name: str) -> str: + """Convert a name to a URL-safe slug.""" + # Remove non-alphanumeric chars except spaces, convert to lowercase + slug = re.sub(r'[^a-zA-Z0-9 ]', '', name.lower()) + # Replace spaces with hyphens + slug = re.sub(r' +', '-', slug.strip()) + return slug + + +class CategoryBase(BaseModel): + name: str = Field(..., min_length=1, max_length=100) + description: Optional[str] = None + + +class CategoryCreate(CategoryBase): + team_id: Optional[UUID] = Field(None, description="Team ID for team-specific category. NULL for global.") + + +class CategoryUpdate(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 CategoryResponse(CategoryBase): + id: UUID + slug: str + team_id: Optional[UUID] = None + display_order: int + is_active: bool + created_at: datetime + updated_at: datetime + tree_count: int = 0 # Computed field + + class Config: + from_attributes = True + + +class CategoryListResponse(BaseModel): + id: UUID + name: str + slug: str + description: Optional[str] = None + team_id: Optional[UUID] = None + display_order: int + is_active: bool + tree_count: int = 0 + + class Config: + from_attributes = True diff --git a/backend/app/schemas/folder.py b/backend/app/schemas/folder.py new file mode 100644 index 00000000..68dd2fde --- /dev/null +++ b/backend/app/schemas/folder.py @@ -0,0 +1,62 @@ +from datetime import datetime +from typing import Optional +from uuid import UUID +from pydantic import BaseModel, Field +import re + + +# Valid hex color pattern +HEX_COLOR_PATTERN = r'^#[0-9A-Fa-f]{6}$' + + +class FolderBase(BaseModel): + name: str = Field(..., min_length=1, max_length=100) + color: str = Field("#6366f1", pattern=HEX_COLOR_PATTERN) + icon: str = Field("folder", max_length=50) + + +class FolderCreate(FolderBase): + parent_id: Optional[UUID] = Field(None, description="Parent folder ID for creating subfolders") + + +class FolderUpdate(BaseModel): + name: Optional[str] = Field(None, min_length=1, max_length=100) + color: Optional[str] = Field(None, pattern=HEX_COLOR_PATTERN) + icon: Optional[str] = Field(None, max_length=50) + display_order: Optional[int] = None + parent_id: Optional[UUID] = Field(None, description="Parent folder ID to move folder") + + +class FolderResponse(FolderBase): + id: UUID + parent_id: Optional[UUID] = None + display_order: int + tree_count: int = 0 # Computed field + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class FolderListResponse(BaseModel): + id: UUID + name: str + color: str + icon: str + parent_id: Optional[UUID] = None + display_order: int + tree_count: int = 0 + + class Config: + from_attributes = True + + +class FolderReorderRequest(BaseModel): + """Request body for reordering folders.""" + folder_ids: list[UUID] = Field(..., min_length=1, description="Folder IDs in desired order") + + +class FolderTreeRequest(BaseModel): + """Request body for adding a tree to a folder.""" + tree_id: UUID diff --git a/backend/app/schemas/tag.py b/backend/app/schemas/tag.py new file mode 100644 index 00000000..2de4bfcf --- /dev/null +++ b/backend/app/schemas/tag.py @@ -0,0 +1,56 @@ +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 tag 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 TagBase(BaseModel): + name: str = Field(..., min_length=1, max_length=50) + + +class TagCreate(TagBase): + team_id: Optional[UUID] = Field(None, description="Team ID for team-specific tag. NULL for global.") + + +class TagResponse(TagBase): + id: UUID + slug: str + team_id: Optional[UUID] = None + usage_count: int + created_at: datetime + + class Config: + from_attributes = True + + +class TagListResponse(BaseModel): + id: UUID + name: str + slug: str + team_id: Optional[UUID] = None + usage_count: int + + class Config: + from_attributes = True + + +class TagAssignment(BaseModel): + """Request body for adding/removing tags from a tree.""" + tags: list[str] = Field(..., min_length=1, max_length=10, description="List of tag names to assign") + + +class TagSearchParams(BaseModel): + """Query parameters for tag search/autocomplete.""" + q: str = Field(..., min_length=1, description="Search query") + limit: int = Field(10, ge=1, le=50) + include_team: bool = Field(True, description="Include team-specific tags") diff --git a/backend/app/schemas/tree.py b/backend/app/schemas/tree.py index 88849aed..de9b3b25 100644 --- a/backend/app/schemas/tree.py +++ b/backend/app/schemas/tree.py @@ -4,9 +4,20 @@ from uuid import UUID from pydantic import BaseModel, Field +class CategoryInfo(BaseModel): + """Embedded category info for tree responses.""" + id: UUID + name: str + slug: str + + class Config: + from_attributes = True + + class TreeBase(BaseModel): name: str = Field(..., min_length=1, max_length=255) description: Optional[str] = None + # Legacy category field - kept for backward compatibility category: Optional[str] = Field(None, max_length=100) @@ -14,15 +25,19 @@ class TreeCreate(TreeBase): tree_structure: dict[str, Any] = Field(..., description="The decision tree structure in JSON format") is_public: bool = Field(False, description="Make tree visible to all users") is_default: bool = Field(False, description="Mark as a default/system tree (admin only)") + category_id: Optional[UUID] = Field(None, description="Category ID from tree_categories table") + tags: Optional[list[str]] = Field(None, max_length=10, description="List of tag names to assign") class TreeUpdate(BaseModel): name: Optional[str] = Field(None, min_length=1, max_length=255) description: Optional[str] = None category: Optional[str] = Field(None, max_length=100) + category_id: Optional[UUID] = None tree_structure: Optional[dict[str, Any]] = None is_public: Optional[bool] = None is_active: Optional[bool] = None + tags: Optional[list[str]] = Field(None, max_length=10, description="List of tag names to assign (replaces existing)") class TreeResponse(TreeBase): @@ -30,6 +45,9 @@ class TreeResponse(TreeBase): tree_structure: dict[str, Any] author_id: Optional[UUID] = None team_id: Optional[UUID] = None + category_id: Optional[UUID] = None + category_info: Optional[CategoryInfo] = None + tags: list[str] = [] # List of tag names is_active: bool is_public: bool is_default: bool @@ -47,6 +65,10 @@ class TreeListResponse(BaseModel): name: str description: Optional[str] = None category: Optional[str] = None + category_id: Optional[UUID] = None + category_info: Optional[CategoryInfo] = None + tags: list[str] = [] # List of tag names + author_id: Optional[UUID] = None is_active: bool is_public: bool is_default: bool diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index 3e10f6c0..888c73b1 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -28,6 +28,7 @@ class UserLogin(BaseModel): class UserResponse(UserBase): id: UUID role: str + is_team_admin: bool = False team_id: Optional[UUID] = None created_at: datetime last_login: Optional[datetime] = None diff --git a/frontend/src/api/categories.ts b/frontend/src/api/categories.ts new file mode 100644 index 00000000..f3b38085 --- /dev/null +++ b/frontend/src/api/categories.ts @@ -0,0 +1,32 @@ +import apiClient from './client' +import type { Category, CategoryListItem, CategoryCreate, CategoryUpdate } from '@/types' + +export const categoriesApi = { + async list(includeInactive = false, teamOnly = false): Promise { + const response = await apiClient.get('/categories', { + params: { include_inactive: includeInactive, team_only: teamOnly }, + }) + return response.data + }, + + async get(id: string): Promise { + const response = await apiClient.get(`/categories/${id}`) + return response.data + }, + + async create(data: CategoryCreate): Promise { + const response = await apiClient.post('/categories', data) + return response.data + }, + + async update(id: string, data: CategoryUpdate): Promise { + const response = await apiClient.put(`/categories/${id}`, data) + return response.data + }, + + async delete(id: string): Promise { + await apiClient.delete(`/categories/${id}`) + }, +} + +export default categoriesApi diff --git a/frontend/src/api/folders.ts b/frontend/src/api/folders.ts new file mode 100644 index 00000000..0570d1e6 --- /dev/null +++ b/frontend/src/api/folders.ts @@ -0,0 +1,50 @@ +import apiClient from './client' +import type { Folder, FolderListItem, FolderCreate, FolderUpdate, FolderReorderRequest } from '@/types' + +export const foldersApi = { + async list(): Promise { + const response = await apiClient.get('/folders') + return response.data + }, + + async get(id: string): Promise { + const response = await apiClient.get(`/folders/${id}`) + return response.data + }, + + async create(data: FolderCreate): Promise { + const response = await apiClient.post('/folders', data) + return response.data + }, + + async update(id: string, data: FolderUpdate): Promise { + const response = await apiClient.put(`/folders/${id}`, data) + return response.data + }, + + async delete(id: string): Promise { + await apiClient.delete(`/folders/${id}`) + }, + + async reorder(folderIds: string[]): Promise { + await apiClient.post('/folders/reorder', { + folder_ids: folderIds, + } as FolderReorderRequest) + }, + + // Folder tree management + async getTreeIds(folderId: string): Promise { + const response = await apiClient.get(`/folders/${folderId}/trees`) + return response.data + }, + + async addTree(folderId: string, treeId: string): Promise { + await apiClient.post(`/folders/${folderId}/trees`, { tree_id: treeId }) + }, + + async removeTree(folderId: string, treeId: string): Promise { + await apiClient.delete(`/folders/${folderId}/trees/${treeId}`) + }, +} + +export default foldersApi diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index c58253bf..7f4a72ca 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -3,3 +3,6 @@ export { default as authApi } from './auth' export { default as treesApi } from './trees' export { default as sessionsApi } from './sessions' export { default as inviteApi } from './invite' +export { default as tagsApi } from './tags' +export { default as categoriesApi } from './categories' +export { default as foldersApi } from './folders' diff --git a/frontend/src/api/tags.ts b/frontend/src/api/tags.ts new file mode 100644 index 00000000..2907209c --- /dev/null +++ b/frontend/src/api/tags.ts @@ -0,0 +1,54 @@ +import apiClient from './client' +import type { Tag, TagListItem, TagCreate, TagAssignment } from '@/types' + +export const tagsApi = { + async list(includeTeam = true): Promise { + const response = await apiClient.get('/tags', { + params: { include_team: includeTeam }, + }) + return response.data + }, + + async search(query: string, limit = 10, includeTeam = true): Promise { + const response = await apiClient.get('/tags/search', { + params: { q: query, limit, include_team: includeTeam }, + }) + return response.data + }, + + async get(id: string): Promise { + const response = await apiClient.get(`/tags/${id}`) + return response.data + }, + + async create(data: TagCreate): Promise { + const response = await apiClient.post('/tags', data) + return response.data + }, + + // Tree tag management + async getTreeTags(treeId: string): Promise { + const response = await apiClient.get(`/tags/trees/${treeId}`) + return response.data + }, + + async addTagsToTree(treeId: string, tags: string[]): Promise { + const response = await apiClient.post(`/tags/trees/${treeId}`, { + tags, + } as TagAssignment) + return response.data + }, + + async replaceTreeTags(treeId: string, tags: string[]): Promise { + const response = await apiClient.put(`/tags/trees/${treeId}`, { + tags, + } as TagAssignment) + return response.data + }, + + async removeTagFromTree(treeId: string, tagSlug: string): Promise { + await apiClient.delete(`/tags/trees/${treeId}/${tagSlug}`) + }, +} + +export default tagsApi diff --git a/frontend/src/api/trees.ts b/frontend/src/api/trees.ts index 2cd996f9..f3b9fa91 100644 --- a/frontend/src/api/trees.ts +++ b/frontend/src/api/trees.ts @@ -1,23 +1,8 @@ import apiClient from './client' -import type { Tree, TreeListItem, TreeCreate, TreeUpdate } from '@/types' - -export interface TreeListParams { - page?: number - size?: number - category?: string - include_inactive?: boolean -} - -export interface TreeListResponse { - items: TreeListItem[] - total: number - page: number - size: number - pages: number -} +import type { Tree, TreeListItem, TreeCreate, TreeUpdate, TreeFilters } from '@/types' export const treesApi = { - async list(params?: TreeListParams): Promise { + async list(params?: TreeFilters): Promise { const response = await apiClient.get('/trees', { params }) return response.data }, @@ -41,14 +26,15 @@ export const treesApi = { await apiClient.delete(`/trees/${id}`) }, - async categories(): Promise { + // Legacy categories endpoint (returns string categories) + async legacyCategories(): Promise { const response = await apiClient.get('/trees/categories') return response.data }, - async search(query: string, category?: string): Promise { + async search(query: string, limit?: number): Promise { const response = await apiClient.get('/trees/search', { - params: { q: query, category }, + params: { q: query, limit }, }) return response.data }, diff --git a/frontend/src/components/common/TagBadges.tsx b/frontend/src/components/common/TagBadges.tsx new file mode 100644 index 00000000..65ffd21b --- /dev/null +++ b/frontend/src/components/common/TagBadges.tsx @@ -0,0 +1,64 @@ +import { cn } from '@/lib/utils' + +interface TagBadgesProps { + tags: string[] + maxVisible?: number + onTagClick?: (tag: string) => void + size?: 'sm' | 'md' + variant?: 'default' | 'muted' +} + +export function TagBadges({ + tags, + maxVisible = 3, + onTagClick, + size = 'sm', + variant = 'default', +}: TagBadgesProps) { + if (!tags || tags.length === 0) return null + + const visibleTags = tags.slice(0, maxVisible) + const hiddenCount = tags.length - maxVisible + + return ( +
+ {visibleTags.map((tag) => ( + + ))} + {hiddenCount > 0 && ( + + +{hiddenCount} more + + )} +
+ ) +} + +export default TagBadges diff --git a/frontend/src/components/common/TagInput.tsx b/frontend/src/components/common/TagInput.tsx new file mode 100644 index 00000000..08c6f260 --- /dev/null +++ b/frontend/src/components/common/TagInput.tsx @@ -0,0 +1,230 @@ +import { useState, useRef, useEffect, useCallback } from 'react' +import { X, Plus } from 'lucide-react' +import { tagsApi } from '@/api' +import type { TagListItem } from '@/types' +import { cn } from '@/lib/utils' + +interface TagInputProps { + tags: string[] + onChange: (tags: string[]) => void + maxTags?: number + placeholder?: string + disabled?: boolean +} + +export function TagInput({ + tags, + onChange, + maxTags = 10, + placeholder = 'Add tags...', + disabled = false, +}: TagInputProps) { + const [inputValue, setInputValue] = useState('') + const [suggestions, setSuggestions] = useState([]) + const [showSuggestions, setShowSuggestions] = useState(false) + const [selectedIndex, setSelectedIndex] = useState(-1) + const inputRef = useRef(null) + const wrapperRef = useRef(null) + + // Debounced search for suggestions + useEffect(() => { + const timer = setTimeout(() => { + if (inputValue.length >= 1) { + tagsApi + .search(inputValue, 5) + .then((results) => { + // Filter out already selected tags + const filtered = results.filter( + (tag) => !tags.includes(tag.name) + ) + setSuggestions(filtered) + setShowSuggestions(filtered.length > 0) + setSelectedIndex(-1) + }) + .catch(console.error) + } else { + setSuggestions([]) + setShowSuggestions(false) + } + }, 200) + + return () => clearTimeout(timer) + }, [inputValue, tags]) + + // Close suggestions on outside click + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) { + setShowSuggestions(false) + } + } + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + }, []) + + const addTag = useCallback( + (tagName: string) => { + const normalized = tagName.trim() + if (!normalized) return + if (tags.length >= maxTags) return + if (tags.includes(normalized)) return + + onChange([...tags, normalized]) + setInputValue('') + setSuggestions([]) + setShowSuggestions(false) + inputRef.current?.focus() + }, + [tags, maxTags, onChange] + ) + + const removeTag = useCallback( + (tagName: string) => { + onChange(tags.filter((t) => t !== tagName)) + }, + [tags, onChange] + ) + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault() + if (selectedIndex >= 0 && suggestions[selectedIndex]) { + addTag(suggestions[selectedIndex].name) + } else if (inputValue.trim()) { + addTag(inputValue) + } + } else if (e.key === 'Backspace' && !inputValue && tags.length > 0) { + removeTag(tags[tags.length - 1]) + } else if (e.key === 'ArrowDown') { + e.preventDefault() + setSelectedIndex((prev) => + prev < suggestions.length - 1 ? prev + 1 : prev + ) + } else if (e.key === 'ArrowUp') { + e.preventDefault() + setSelectedIndex((prev) => (prev > 0 ? prev - 1 : -1)) + } else if (e.key === 'Escape') { + setShowSuggestions(false) + setSelectedIndex(-1) + } else if (e.key === ',' || e.key === 'Tab') { + if (inputValue.trim()) { + e.preventDefault() + addTag(inputValue) + } + } + } + + return ( +
+
inputRef.current?.focus()} + > + {/* Tag chips */} + {tags.map((tag) => ( + + {tag} + {!disabled && ( + + )} + + ))} + + {/* Input field */} + {tags.length < maxTags && ( + setInputValue(e.target.value)} + onKeyDown={handleKeyDown} + onFocus={() => { + if (inputValue.length >= 1 && suggestions.length > 0) { + setShowSuggestions(true) + } + }} + placeholder={tags.length === 0 ? placeholder : ''} + disabled={disabled} + className={cn( + 'flex-1 min-w-[80px] border-0 bg-transparent px-1 py-0.5 text-sm', + 'placeholder:text-muted-foreground', + 'focus:outline-none focus:ring-0' + )} + /> + )} +
+ + {/* Suggestions dropdown */} + {showSuggestions && suggestions.length > 0 && ( +
+ {suggestions.map((suggestion, index) => ( + + ))} + {inputValue.trim() && + !suggestions.some( + (s) => s.name.toLowerCase() === inputValue.toLowerCase() + ) && ( + + )} +
+ )} + + {/* Helper text */} +

+ {tags.length}/{maxTags} tags. Press Enter or comma to add. +

+
+ ) +} + +export default TagInput diff --git a/frontend/src/components/library/AddToFolderMenu.tsx b/frontend/src/components/library/AddToFolderMenu.tsx new file mode 100644 index 00000000..78ffff6f --- /dev/null +++ b/frontend/src/components/library/AddToFolderMenu.tsx @@ -0,0 +1,155 @@ +import { useState, useEffect, useRef } from 'react' +import { FolderPlus, Check, Plus } from 'lucide-react' +import { foldersApi } from '@/api' +import type { FolderListItem } from '@/types' +import { cn } from '@/lib/utils' + +interface AddToFolderMenuProps { + treeId: string + onFolderCreated?: () => void +} + +export function AddToFolderMenu({ treeId, onFolderCreated }: AddToFolderMenuProps) { + const [isOpen, setIsOpen] = useState(false) + const [folders, setFolders] = useState([]) + const [treeFolderIds, setTreeFolderIds] = useState>(new Set()) + const [isLoading, setIsLoading] = useState(false) + const menuRef = useRef(null) + + useEffect(() => { + if (isOpen) { + loadFoldersAndAssignments() + } + }, [isOpen, treeId]) + + // Close on outside click + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + setIsOpen(false) + } + } + if (isOpen) { + document.addEventListener('mousedown', handleClickOutside) + } + return () => document.removeEventListener('mousedown', handleClickOutside) + }, [isOpen]) + + const loadFoldersAndAssignments = async () => { + setIsLoading(true) + try { + const [foldersData, allFolders] = await Promise.all([ + foldersApi.list(), + Promise.resolve([]), // Will load tree's folder assignments below + ]) + setFolders(foldersData) + + // Check which folders contain this tree + const folderIds = new Set() + for (const folder of foldersData) { + try { + const treeIds = await foldersApi.getTreeIds(folder.id) + if (treeIds.includes(treeId)) { + folderIds.add(folder.id) + } + } catch { + // Ignore errors for individual folder checks + } + } + setTreeFolderIds(folderIds) + } catch (err) { + console.error('Failed to load folders:', err) + } finally { + setIsLoading(false) + } + } + + const toggleFolder = async (folderId: string) => { + try { + if (treeFolderIds.has(folderId)) { + await foldersApi.removeTree(folderId, treeId) + setTreeFolderIds((prev) => { + const next = new Set(prev) + next.delete(folderId) + return next + }) + } else { + await foldersApi.addTree(folderId, treeId) + setTreeFolderIds((prev) => new Set([...prev, folderId])) + } + // Dispatch event to refresh folder counts + window.dispatchEvent(new Event('folder-changed')) + } catch (err) { + console.error('Failed to toggle folder:', err) + } + } + + return ( +
+ + + {isOpen && ( +
+ {isLoading ? ( +
Loading...
+ ) : folders.length === 0 ? ( +
No folders yet
+ ) : ( + folders.map((folder) => ( + + )) + )} + +
+ + +
+ )} +
+ ) +} + +export default AddToFolderMenu diff --git a/frontend/src/components/library/FolderEditModal.tsx b/frontend/src/components/library/FolderEditModal.tsx new file mode 100644 index 00000000..4a173622 --- /dev/null +++ b/frontend/src/components/library/FolderEditModal.tsx @@ -0,0 +1,291 @@ +import { useState, useEffect, useMemo } from 'react' +import { X } from 'lucide-react' +import { foldersApi } from '@/api' +import type { FolderListItem, FolderCreate, FolderUpdate } from '@/types' +import { cn } from '@/lib/utils' + +// Predefined color options +const FOLDER_COLORS = [ + '#6366f1', // Indigo (default) + '#8b5cf6', // Violet + '#ec4899', // Pink + '#ef4444', // Red + '#f97316', // Orange + '#eab308', // Yellow + '#22c55e', // Green + '#14b8a6', // Teal + '#3b82f6', // Blue + '#64748b', // Slate +] + +interface FolderEditModalProps { + folder: FolderListItem | null // null for create mode + parentId?: string | null // Pre-selected parent for creating subfolders + folders: FolderListItem[] // All folders for parent dropdown + isOpen: boolean + onClose: () => void + onSave: () => void +} + +// Get all descendant IDs of a folder (to prevent cycles) +function getDescendantIds(folders: FolderListItem[], folderId: string): Set { + const descendants = new Set() + const children = folders.filter((f) => f.parent_id === folderId) + children.forEach((child) => { + descendants.add(child.id) + getDescendantIds(folders, child.id).forEach((id) => descendants.add(id)) + }) + return descendants +} + +// Calculate folder depth +function getFolderDepth(folders: FolderListItem[], folderId: string | null): number { + if (!folderId) return 0 + const folder = folders.find((f) => f.id === folderId) + if (!folder || !folder.parent_id) return 1 + return 1 + getFolderDepth(folders, folder.parent_id) +} + +// Get indented folder name for dropdown display +function getIndentedName(folders: FolderListItem[], folderId: string): string { + const depth = getFolderDepth(folders, folderId) + const folder = folders.find((f) => f.id === folderId) + const indent = ' '.repeat(depth - 1) + return indent + (depth > 1 ? '└ ' : '') + (folder?.name || '') +} + +export function FolderEditModal({ + folder, + parentId: initialParentId, + folders, + isOpen, + onClose, + onSave, +}: FolderEditModalProps) { + const [name, setName] = useState('') + const [color, setColor] = useState(FOLDER_COLORS[0]) + const [parentId, setParentId] = useState(null) + const [isSubmitting, setIsSubmitting] = useState(false) + const [error, setError] = useState(null) + + const isEditMode = folder !== null + + // Build list of valid parent options + const parentOptions = useMemo(() => { + // Can't be own parent, can't create cycles + const invalidIds = new Set() + + if (folder) { + // Exclude self and all descendants + invalidIds.add(folder.id) + getDescendantIds(folders, folder.id).forEach((id) => invalidIds.add(id)) + } + + // Filter to valid parents only (max depth 2 so that new folder is at depth 3) + return folders + .filter((f) => !invalidIds.has(f.id)) + .filter((f) => { + const depth = getFolderDepth(folders, f.id) + // If creating new folder, parent can be at depth 1 or 2 (new folder at 2 or 3) + // If editing, we need to check if moving would exceed depth limit + if (folder) { + // Get max depth of folder's subtree + const getMaxSubtreeDepth = (folderId: string): number => { + const children = folders.filter((c) => c.parent_id === folderId) + if (children.length === 0) return 0 + return 1 + Math.max(...children.map((c) => getMaxSubtreeDepth(c.id))) + } + const subtreeDepth = getMaxSubtreeDepth(folder.id) + // New parent depth + 1 (for this folder) + subtree must be <= 3 + return depth + 1 + subtreeDepth <= 3 + } + return depth < 3 // Can add child to folders at depth 1 or 2 + }) + .sort((a, b) => { + // Sort by hierarchy for better UX + const aPath = getPath(folders, a.id) + const bPath = getPath(folders, b.id) + return aPath.localeCompare(bPath) + }) + }, [folder, folders]) + + // Get path string for sorting + function getPath(allFolders: FolderListItem[], folderId: string): string { + const f = allFolders.find((x) => x.id === folderId) + if (!f) return '' + if (!f.parent_id) return f.name + return getPath(allFolders, f.parent_id) + '/' + f.name + } + + useEffect(() => { + if (folder) { + setName(folder.name) + setColor(folder.color) + setParentId(folder.parent_id || null) + } else { + setName('') + setColor(FOLDER_COLORS[0]) + setParentId(initialParentId || null) + } + setError(null) + }, [folder, initialParentId, isOpen]) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError(null) + + if (!name.trim()) { + setError('Folder name is required') + return + } + + setIsSubmitting(true) + try { + if (isEditMode && folder) { + const updateData: FolderUpdate = { name, color } + // Only include parent_id if it changed + if (parentId !== folder.parent_id) { + updateData.parent_id = parentId + } + await foldersApi.update(folder.id, updateData) + } else { + const createData: FolderCreate = { name, color } + if (parentId) { + createData.parent_id = parentId + } + await foldersApi.create(createData) + } + onSave() + onClose() + // Dispatch event to refresh folder list + window.dispatchEvent(new Event('folder-changed')) + } catch (err: any) { + setError(err.response?.data?.detail || 'Failed to save folder') + } finally { + setIsSubmitting(false) + } + } + + if (!isOpen) return null + + return ( +
+ {/* Backdrop */} +
+ + {/* Modal */} +
+
+

+ {isEditMode ? 'Edit Folder' : initialParentId ? 'Create Subfolder' : 'Create Folder'} +

+ +
+ +
+ {/* Name input */} +
+ + setName(e.target.value)} + placeholder="e.g., Citrix Issues" + className={cn( + 'mt-1 block w-full rounded-md border px-3 py-2 text-sm', + 'bg-background text-foreground placeholder:text-muted-foreground', + 'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary', + 'border-input' + )} + autoFocus + /> +
+ + {/* Parent folder dropdown */} +
+ + +

+ Folders can be nested up to 3 levels deep. +

+
+ + {/* Color picker */} +
+ +
+ {FOLDER_COLORS.map((c) => ( +
+
+ + {/* Error message */} + {error && ( +
+ {error} +
+ )} + + {/* Actions */} +
+ + +
+
+
+
+ ) +} + +export default FolderEditModal diff --git a/frontend/src/components/library/FolderSidebar.tsx b/frontend/src/components/library/FolderSidebar.tsx new file mode 100644 index 00000000..92662146 --- /dev/null +++ b/frontend/src/components/library/FolderSidebar.tsx @@ -0,0 +1,484 @@ +import { useState, useEffect, useCallback } from 'react' +import { Folder, ChevronDown, ChevronRight, Plus, MoreVertical, Pencil, Trash2, FolderPlus } from 'lucide-react' +import { foldersApi } from '@/api' +import type { FolderListItem, FolderTreeItem } from '@/types' +import { cn } from '@/lib/utils' + +interface FolderSidebarProps { + selectedFolderId: string | null + onFolderSelect: (folderId: string | null) => void + onCreateFolder: (parentId?: string | null) => void + onEditFolder: (folder: FolderListItem) => void +} + +// Build tree structure from flat folder list +function buildFolderTree(folders: FolderListItem[]): FolderTreeItem[] { + const folderMap = new Map() + const rootFolders: FolderTreeItem[] = [] + + // First pass: create all folder items + folders.forEach((folder) => { + folderMap.set(folder.id, { + ...folder, + children: [], + isExpanded: true, // Default expanded + }) + }) + + // Second pass: build parent-child relationships + folders.forEach((folder) => { + const treeItem = folderMap.get(folder.id)! + if (folder.parent_id && folderMap.has(folder.parent_id)) { + const parent = folderMap.get(folder.parent_id)! + parent.children.push(treeItem) + } else { + rootFolders.push(treeItem) + } + }) + + // Sort children by display_order + const sortChildren = (items: FolderTreeItem[]) => { + items.sort((a, b) => a.display_order - b.display_order) + items.forEach((item) => sortChildren(item.children)) + } + sortChildren(rootFolders) + + return rootFolders +} + +// Calculate folder depth (for limiting nesting) +function getFolderDepth(folders: FolderListItem[], folderId: string): number { + const folder = folders.find((f) => f.id === folderId) + if (!folder || !folder.parent_id) return 1 + return 1 + getFolderDepth(folders, folder.parent_id) +} + +// Check if folder has children +function hasChildren(folders: FolderListItem[], folderId: string): boolean { + return folders.some((f) => f.parent_id === folderId) +} + +// Get all descendant IDs (for cascade delete warning) +function getDescendantIds(folders: FolderListItem[], folderId: string): string[] { + const children = folders.filter((f) => f.parent_id === folderId) + const descendantIds: string[] = [] + children.forEach((child) => { + descendantIds.push(child.id) + descendantIds.push(...getDescendantIds(folders, child.id)) + }) + return descendantIds +} + +interface ContextMenuState { + x: number + y: number + folder: FolderTreeItem + canAddSubfolder: boolean +} + +interface FolderItemProps { + folder: FolderTreeItem + depth: number + selectedFolderId: string | null + expandedIds: Set + menuOpenId: string | null + onFolderSelect: (folderId: string) => void + onToggleExpand: (folderId: string) => void + onMenuToggle: (folderId: string | null) => void + onEditFolder: (folder: FolderListItem) => void + onAddSubfolder: (parentId: string) => void + onDeleteFolder: (folderId: string, hasChildren: boolean) => void + onContextMenu: (e: React.MouseEvent, folder: FolderTreeItem, canAddSubfolder: boolean) => void + canAddSubfolder: boolean +} + +function FolderItem({ + folder, + depth, + selectedFolderId, + expandedIds, + menuOpenId, + onFolderSelect, + onToggleExpand, + onMenuToggle, + onEditFolder, + onAddSubfolder, + onDeleteFolder, + onContextMenu, + canAddSubfolder, +}: FolderItemProps) { + const isExpanded = expandedIds.has(folder.id) + const hasSubfolders = folder.children.length > 0 + + const handleContextMenu = (e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() + onContextMenu(e, folder, canAddSubfolder) + } + + return ( +
+
+ + ) : ( + // Spacer for alignment + )} + + {folder.name} + {folder.tree_count} + + + {/* Folder menu button */} + + + {/* Dropdown menu */} + {menuOpenId === folder.id && ( +
+ + {canAddSubfolder && ( + + )} + +
+ )} +
+ + {/* Children */} + {isExpanded && hasSubfolders && ( +
+ {folder.children.map((child) => ( + + ))} +
+ )} +
+ ) +} + +export function FolderSidebar({ + selectedFolderId, + onFolderSelect, + onCreateFolder, + onEditFolder, +}: FolderSidebarProps) { + const [folders, setFolders] = useState([]) + const [folderTree, setFolderTree] = useState([]) + const [isExpanded, setIsExpanded] = useState(true) + const [isLoading, setIsLoading] = useState(true) + const [menuOpenId, setMenuOpenId] = useState(null) + const [expandedIds, setExpandedIds] = useState>(new Set()) + const [contextMenu, setContextMenu] = useState(null) + + const loadFolders = useCallback(async () => { + setIsLoading(true) + try { + const data = await foldersApi.list() + setFolders(data) + setFolderTree(buildFolderTree(data)) + // Expand all by default + setExpandedIds(new Set(data.map((f) => f.id))) + } catch (err) { + console.error('Failed to load folders:', err) + } finally { + setIsLoading(false) + } + }, []) + + useEffect(() => { + loadFolders() + }, [loadFolders]) + + const handleToggleExpand = (folderId: string) => { + setExpandedIds((prev) => { + const next = new Set(prev) + if (next.has(folderId)) { + next.delete(folderId) + } else { + next.add(folderId) + } + return next + }) + } + + const handleDeleteFolder = async (folderId: string, folderHasChildren: boolean) => { + const descendantCount = getDescendantIds(folders, folderId).length + const message = folderHasChildren + ? `Are you sure you want to delete this folder and its ${descendantCount} subfolder(s)? The trees in them will not be deleted.` + : 'Are you sure you want to delete this folder? The trees in it will not be deleted.' + + if (!confirm(message)) { + return + } + try { + await foldersApi.delete(folderId) + // Remove folder and all descendants from local state + const idsToRemove = new Set([folderId, ...getDescendantIds(folders, folderId)]) + const updatedFolders = folders.filter((f) => !idsToRemove.has(f.id)) + setFolders(updatedFolders) + setFolderTree(buildFolderTree(updatedFolders)) + if (selectedFolderId && idsToRemove.has(selectedFolderId)) { + onFolderSelect(null) + } + } catch (err) { + console.error('Failed to delete folder:', err) + } + } + + const handleAddSubfolder = (parentId: string) => { + onCreateFolder(parentId) + } + + const handleContextMenu = (e: React.MouseEvent, folder: FolderTreeItem, canAddSubfolder: boolean) => { + setContextMenu({ + x: e.clientX, + y: e.clientY, + folder, + canAddSubfolder, + }) + setMenuOpenId(null) // Close any open hover menu + } + + const closeContextMenu = () => { + setContextMenu(null) + } + + // Refresh folders when a folder is edited or created + useEffect(() => { + const handleFolderChange = () => loadFolders() + window.addEventListener('folder-changed', handleFolderChange) + return () => window.removeEventListener('folder-changed', handleFolderChange) + }, [loadFolders]) + + // Close hover menu when clicking outside + useEffect(() => { + if (!menuOpenId) return + const handleClickOutside = () => setMenuOpenId(null) + document.addEventListener('click', handleClickOutside) + return () => document.removeEventListener('click', handleClickOutside) + }, [menuOpenId]) + + // Close context menu on click outside, right-click elsewhere, or Escape + useEffect(() => { + if (!contextMenu) return + const close = () => setContextMenu(null) + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') close() + } + document.addEventListener('click', close) + document.addEventListener('contextmenu', close) + document.addEventListener('keydown', handleKeyDown) + return () => { + document.removeEventListener('click', close) + document.removeEventListener('contextmenu', close) + document.removeEventListener('keydown', handleKeyDown) + } + }, [contextMenu]) + + return ( + <> +
+
+ + + {isExpanded && ( +
+ {/* All Trees */} + + + {/* Loading state */} + {isLoading ? ( +
Loading...
+ ) : ( + <> + {/* User folders (hierarchical) */} + {folderTree.map((folder) => ( + + ))} + + )} + + {/* Create folder button */} + +
+ )} +
+
+ + {/* Right-click context menu */} + {contextMenu && ( +
e.stopPropagation()} + > + + {contextMenu.canAddSubfolder && ( + + )} + +
+ )} + + ) +} + +export default FolderSidebar diff --git a/frontend/src/components/tree-editor/TreeMetadataForm.tsx b/frontend/src/components/tree-editor/TreeMetadataForm.tsx index cc672ca4..41fe0e3a 100644 --- a/frontend/src/components/tree-editor/TreeMetadataForm.tsx +++ b/frontend/src/components/tree-editor/TreeMetadataForm.tsx @@ -1,27 +1,54 @@ import { useEffect, useState } from 'react' -import { treesApi } from '@/api' +import { categoriesApi } from '@/api' import { useTreeEditorStore } from '@/store/treeEditorStore' +import { TagInput } from '@/components/common/TagInput' +import type { CategoryListItem } from '@/types' import { cn } from '@/lib/utils' +import { Globe, Lock } from 'lucide-react' export function TreeMetadataForm() { - const { name, description, category, setName, setDescription, setCategory, validationErrors } = - useTreeEditorStore() + const { + name, + description, + category, + categoryId, + tags, + isPublic, + setName, + setDescription, + setCategory, + setCategoryId, + setTags, + setIsPublic, + validationErrors, + } = useTreeEditorStore() - const [categories, setCategories] = useState([]) + const [categories, setCategories] = useState([]) + const [legacyCategories, setLegacyCategories] = useState([]) const [customCategory, setCustomCategory] = useState(false) - // Load existing categories + // Load categories useEffect(() => { - treesApi.categories().then(setCategories).catch(console.error) + categoriesApi.list().then(setCategories).catch(console.error) }, []) const handleCategoryChange = (value: string) => { if (value === '__custom__') { setCustomCategory(true) setCategory('') + setCategoryId(null) + } else if (value === '') { + setCustomCategory(false) + setCategory('') + setCategoryId(null) } else { setCustomCategory(false) - setCategory(value) + setCategoryId(value) + // Find category name for display + const cat = categories.find((c) => c.id === value) + if (cat) { + setCategory(cat.name) + } } } @@ -51,9 +78,7 @@ export function TreeMetadataForm() { nameError ? 'border-destructive' : 'border-input' )} /> - {nameError && ( -

{nameError.message}

- )} + {nameError &&

{nameError.message}

}
{/* Description */} @@ -83,7 +108,7 @@ export function TreeMetadataForm() { {!customCategory ? ( setIsPublic(false)} + className="sr-only" + /> + + Private + + +
+

+ {isPublic + ? 'Anyone can view this tree' + : 'Only you and your team can view this tree'} +

+ ) } diff --git a/frontend/src/pages/TreeEditorPage.tsx b/frontend/src/pages/TreeEditorPage.tsx index ea145c08..5bf947a2 100644 --- a/frontend/src/pages/TreeEditorPage.tsx +++ b/frontend/src/pages/TreeEditorPage.tsx @@ -138,12 +138,14 @@ export function TreeEditorPage() { const treeData = getTreeForSave() if (isEditMode) { await treesApi.update(id!, treeData as TreeUpdate) + markSaved() } else { const newTree = await treesApi.create(treeData as TreeCreate) + // Mark saved BEFORE navigating to avoid triggering the blocker + markSaved() // Navigate to edit mode with the new ID navigate(`/trees/${newTree.id}/edit`, { replace: true }) } - markSaved() } catch (err) { console.error('Failed to save tree:', err) setSaveError('Failed to save tree. Please try again.') diff --git a/frontend/src/pages/TreeLibraryPage.tsx b/frontend/src/pages/TreeLibraryPage.tsx index cd939fcf..b1a3e353 100644 --- a/frontend/src/pages/TreeLibraryPage.tsx +++ b/frontend/src/pages/TreeLibraryPage.tsx @@ -1,30 +1,63 @@ -import { useEffect, useState } from 'react' +import { useEffect, useState, useCallback } from 'react' import { useNavigate, Link } from 'react-router-dom' -import { Plus, Pencil } from 'lucide-react' -import { treesApi } from '@/api' -import type { TreeListItem } from '@/types' +import { Plus, Pencil, Globe, Lock, X } from 'lucide-react' +import { treesApi, categoriesApi, foldersApi } from '@/api' +import type { TreeListItem, CategoryListItem, FolderListItem } from '@/types' +import { TagBadges } from '@/components/common/TagBadges' +import { FolderSidebar } from '@/components/library/FolderSidebar' +import { FolderEditModal } from '@/components/library/FolderEditModal' +import { AddToFolderMenu } from '@/components/library/AddToFolderMenu' import { cn } from '@/lib/utils' export function TreeLibraryPage() { const navigate = useNavigate() const [trees, setTrees] = useState([]) - const [categories, setCategories] = useState([]) - const [selectedCategory, setSelectedCategory] = useState('') + const [categories, setCategories] = useState([]) + const [folders, setFolders] = useState([]) + const [selectedCategoryId, setSelectedCategoryId] = useState('') + const [selectedTags, setSelectedTags] = useState([]) + const [selectedFolderId, setSelectedFolderId] = useState(null) const [searchQuery, setSearchQuery] = useState('') const [isLoading, setIsLoading] = useState(true) const [error, setError] = useState(null) + // Folder modal state + const [folderModalOpen, setFolderModalOpen] = useState(false) + const [editingFolder, setEditingFolder] = useState(null) + const [newFolderParentId, setNewFolderParentId] = useState(null) + + const loadFolders = useCallback(async () => { + try { + const foldersData = await foldersApi.list() + setFolders(foldersData) + } catch (err) { + console.error('Failed to load folders:', err) + } + }, []) + useEffect(() => { loadData() - }, [selectedCategory]) + }, [selectedCategoryId, selectedTags, selectedFolderId]) + + // Load folders on mount and listen for changes + useEffect(() => { + loadFolders() + const handleFolderChange = () => loadFolders() + window.addEventListener('folder-changed', handleFolderChange) + return () => window.removeEventListener('folder-changed', handleFolderChange) + }, [loadFolders]) const loadData = async () => { setIsLoading(true) setError(null) try { const [treesData, categoriesData] = await Promise.all([ - treesApi.list({ category: selectedCategory || undefined }), - treesApi.categories(), + treesApi.list({ + category_id: selectedCategoryId || undefined, + tags: selectedTags.length > 0 ? selectedTags.join(',') : undefined, + folder_id: selectedFolderId || undefined, + }), + categoriesApi.list(), ]) setTrees(treesData) setCategories(categoriesData) @@ -44,7 +77,7 @@ export function TreeLibraryPage() { setIsLoading(true) setError(null) try { - const results = await treesApi.search(searchQuery, selectedCategory || undefined) + const results = await treesApi.search(searchQuery) setTrees(results) } catch (err) { setError('Search failed') @@ -54,140 +87,262 @@ export function TreeLibraryPage() { } } + const handleTagClick = (tag: string) => { + if (!selectedTags.includes(tag)) { + setSelectedTags([...selectedTags, tag]) + } + } + + const removeTagFilter = (tag: string) => { + setSelectedTags(selectedTags.filter((t) => t !== tag)) + } + + const clearAllFilters = () => { + setSelectedCategoryId('') + setSelectedTags([]) + setSelectedFolderId(null) + setSearchQuery('') + } + const handleStartSession = (treeId: string) => { navigate(`/trees/${treeId}/navigate`) } + const handleCreateFolder = (parentId?: string | null) => { + setEditingFolder(null) + setNewFolderParentId(parentId || null) + setFolderModalOpen(true) + } + + const handleEditFolder = (folder: FolderListItem) => { + setEditingFolder(folder) + setNewFolderParentId(null) + setFolderModalOpen(true) + } + + const hasActiveFilters = + selectedCategoryId || selectedTags.length > 0 || searchQuery || selectedFolderId + return ( -
-
-
-

Decision Trees

-

- Select a troubleshooting tree to start a new session -

-
- - - Create Tree - -
+
+ {/* Folder Sidebar */} + - {/* Search and Filter */} -
-
- setSearchQuery(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && handleSearch()} - className={cn( - 'flex-1 rounded-md border border-input bg-background px-3 py-2', - 'text-foreground placeholder:text-muted-foreground', - 'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary' - )} - /> - -
- - -
- - {/* Error State */} - {error && ( -
- {error} -
- )} - - {/* Loading State */} - {isLoading ? ( -
-
-
- ) : trees.length === 0 ? ( -
- No trees found. {searchQuery && 'Try adjusting your search.'} -
- ) : ( -
- {trees.map((tree) => ( -
-
-

{tree.name}

- {tree.category && ( - - {tree.category} - - )} -
-

- {tree.description || 'No description available'} + {/* Main Content */} +

+
+
+
+

Decision Trees

+

+ Select a troubleshooting tree to start a new session

-
- - v{tree.version} · {tree.usage_count} uses - -
- - - - -
-
- ))} + + + Create Tree + +
+ + {/* Search and Filter */} +
+
+ setSearchQuery(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleSearch()} + className={cn( + 'flex-1 rounded-md border border-input bg-background px-3 py-2', + 'text-foreground placeholder:text-muted-foreground', + 'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary' + )} + /> + +
+ + +
+ + {/* Active Filters */} + {hasActiveFilters && ( +
+ Filters: + {selectedFolderId && ( + + Folder + + + )} + {selectedCategoryId && ( + + {categories.find((c) => c.id === selectedCategoryId)?.name} + + + )} + {selectedTags.map((tag) => ( + + {tag} + + + ))} + +
+ )} + + {/* Error State */} + {error && ( +
{error}
+ )} + + {/* Loading State */} + {isLoading ? ( +
+
+
+ ) : trees.length === 0 ? ( +
+ No trees found.{' '} + {(searchQuery || hasActiveFilters) && 'Try adjusting your filters.'} +
+ ) : ( +
+ {trees.map((tree) => ( +
+
+

{tree.name}

+
+ {tree.is_public ? ( + + ) : ( + + )} + {tree.category_info && ( + + {tree.category_info.name} + + )} +
+
+

+ {tree.description || 'No description available'} +

+ + {/* Tags */} + {tree.tags && tree.tags.length > 0 && ( +
+ +
+ )} + +
+ + v{tree.version} · {tree.usage_count} uses + +
+ + + + + +
+
+
+ ))} +
+ )}
- )} +
+ + {/* Folder Edit Modal */} + { + setFolderModalOpen(false) + setNewFolderParentId(null) + }} + onSave={loadData} + />
) } diff --git a/frontend/src/store/treeEditorStore.ts b/frontend/src/store/treeEditorStore.ts index 5eb4c22c..ff2a41b3 100644 --- a/frontend/src/store/treeEditorStore.ts +++ b/frontend/src/store/treeEditorStore.ts @@ -101,6 +101,9 @@ interface TreeEditorState { name: string description: string category: string + categoryId: string | null + tags: string[] + isPublic: boolean treeStructure: TreeStructure | null originalTree: Tree | null // For comparison in edit mode @@ -127,6 +130,11 @@ interface TreeEditorState { setName: (name: string) => void setDescription: (description: string) => void setCategory: (category: string) => void + setCategoryId: (categoryId: string | null) => void + setTags: (tags: string[]) => void + addTag: (tag: string) => void + removeTag: (tag: string) => void + setIsPublic: (isPublic: boolean) => void // Actions - Node CRUD addNode: (parentId: string | null, type: NodeType, insertIndex?: number) => string @@ -169,6 +177,9 @@ export const useTreeEditorStore = create()( name: '', description: '', category: '', + categoryId: null, + tags: [], + isPublic: false, treeStructure: null, originalTree: null, selectedNodeId: null, @@ -188,6 +199,9 @@ export const useTreeEditorStore = create()( state.name = '' state.description = '' state.category = '' + state.categoryId = null + state.tags = [] + state.isPublic = false state.treeStructure = { id: 'root', type: 'decision', @@ -213,6 +227,9 @@ export const useTreeEditorStore = create()( state.name = tree.name state.description = tree.description || '' state.category = tree.category || '' + state.categoryId = tree.category_id || null + state.tags = tree.tags || [] + state.isPublic = tree.is_public || false state.treeStructure = tree.tree_structure state.originalTree = tree state.selectedNodeId = tree.tree_structure?.id || null @@ -236,6 +253,9 @@ export const useTreeEditorStore = create()( state.name = draft.name || '' state.description = draft.description || '' state.category = draft.category || '' + state.categoryId = draft.categoryId || null + state.tags = draft.tags || [] + state.isPublic = draft.isPublic || false state.treeStructure = draft.treeStructure || null state.isDirty = true state.draftSavedAt = draft.savedAt ? new Date(draft.savedAt) : null @@ -261,6 +281,9 @@ export const useTreeEditorStore = create()( state.name = '' state.description = '' state.category = '' + state.categoryId = null + state.tags = [] + state.isPublic = false state.treeStructure = null state.originalTree = null state.selectedNodeId = null @@ -299,6 +322,48 @@ export const useTreeEditorStore = create()( get().autoSaveDraft() }, + setCategoryId: (categoryId: string | null) => { + set((state) => { + state.categoryId = categoryId + state.isDirty = true + }) + get().autoSaveDraft() + }, + + setTags: (tags: string[]) => { + set((state) => { + state.tags = tags + state.isDirty = true + }) + get().autoSaveDraft() + }, + + addTag: (tag: string) => { + set((state) => { + if (!state.tags.includes(tag)) { + state.tags.push(tag) + state.isDirty = true + } + }) + get().autoSaveDraft() + }, + + removeTag: (tag: string) => { + set((state) => { + state.tags = state.tags.filter(t => t !== tag) + state.isDirty = true + }) + get().autoSaveDraft() + }, + + setIsPublic: (isPublic: boolean) => { + set((state) => { + state.isPublic = isPublic + state.isDirty = true + }) + get().autoSaveDraft() + }, + // Node CRUD addNode: (parentId: string | null, type: NodeType, insertIndex?: number) => { const newId = generateId() @@ -605,6 +670,9 @@ export const useTreeEditorStore = create()( name: state.name, description: state.description, category: state.category, + categoryId: state.categoryId, + tags: state.tags, + isPublic: state.isPublic, treeStructure: state.treeStructure, savedAt: new Date().toISOString() } @@ -628,6 +696,9 @@ export const useTreeEditorStore = create()( name: state.name, description: state.description || undefined, category: state.category || undefined, + category_id: state.categoryId || undefined, + tags: state.tags.length > 0 ? state.tags : undefined, + is_public: state.isPublic, tree_structure: state.treeStructure! } }, @@ -694,6 +765,9 @@ export const useTreeEditorStore = create()( name: state.name, description: state.description, category: state.category, + categoryId: state.categoryId, + tags: state.tags, + isPublic: state.isPublic, treeStructure: state.treeStructure }) } diff --git a/frontend/src/types/category.ts b/frontend/src/types/category.ts new file mode 100644 index 00000000..6a318768 --- /dev/null +++ b/frontend/src/types/category.ts @@ -0,0 +1,45 @@ +// Category types for tree organization + +export interface Category { + id: string + name: string + slug: string + description: string | null + team_id: string | null + display_order: number + is_active: boolean + created_at: string + updated_at: string + tree_count: number +} + +export interface CategoryListItem { + id: string + name: string + slug: string + description: string | null + team_id: string | null + display_order: number + is_active: boolean + tree_count: number +} + +export interface CategoryCreate { + name: string + description?: string | null + team_id?: string | null +} + +export interface CategoryUpdate { + name?: string + description?: string | null + display_order?: number + is_active?: boolean +} + +// Embedded category info for tree responses +export interface CategoryInfo { + id: string + name: string + slug: string +} diff --git a/frontend/src/types/folder.ts b/frontend/src/types/folder.ts new file mode 100644 index 00000000..4d36159f --- /dev/null +++ b/frontend/src/types/folder.ts @@ -0,0 +1,52 @@ +// Folder types for user tree organization + +export interface Folder { + id: string + name: string + color: string + icon: string + parent_id: string | null + display_order: number + tree_count: number + created_at: string + updated_at: string +} + +export interface FolderListItem { + id: string + name: string + color: string + icon: string + parent_id: string | null + display_order: number + tree_count: number +} + +export interface FolderCreate { + name: string + color?: string + icon?: string + parent_id?: string | null +} + +export interface FolderUpdate { + name?: string + color?: string + icon?: string + display_order?: number + parent_id?: string | null +} + +export interface FolderReorderRequest { + folder_ids: string[] +} + +export interface FolderTreeRequest { + tree_id: string +} + +// For hierarchical display of folders +export interface FolderTreeItem extends FolderListItem { + children: FolderTreeItem[] + isExpanded?: boolean +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index f21606b5..ed2e45e8 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -3,6 +3,9 @@ export * from './auth' export * from './tree' export * from './session' export * from './invite' +export * from './tag' +export * from './category' +export * from './folder' // API response wrapper types export interface PaginatedResponse { diff --git a/frontend/src/types/tag.ts b/frontend/src/types/tag.ts new file mode 100644 index 00000000..e33e2a9a --- /dev/null +++ b/frontend/src/types/tag.ts @@ -0,0 +1,27 @@ +// Tag types for tree organization + +export interface Tag { + id: string + name: string + slug: string + team_id: string | null + usage_count: number + created_at: string +} + +export interface TagListItem { + id: string + name: string + slug: string + team_id: string | null + usage_count: number +} + +export interface TagCreate { + name: string + team_id?: string | null +} + +export interface TagAssignment { + tags: string[] +} diff --git a/frontend/src/types/tree.ts b/frontend/src/types/tree.ts index 2b36695d..7755a8ba 100644 --- a/frontend/src/types/tree.ts +++ b/frontend/src/types/tree.ts @@ -1,3 +1,5 @@ +import type { CategoryInfo } from './category' + // Tree node types export type NodeType = 'decision' | 'action' | 'solution' @@ -60,10 +62,15 @@ export interface Tree { name: string description: string | null category: string | null + category_id: string | null + category_info: CategoryInfo | null + tags: string[] tree_structure: TreeStructure author_id: string | null team_id: string | null is_active: boolean + is_public: boolean + is_default: boolean version: number created_at: string updated_at: string @@ -75,7 +82,13 @@ export interface TreeListItem { name: string description: string | null category: string | null + category_id: string | null + category_info: CategoryInfo | null + tags: string[] + author_id: string | null is_active: boolean + is_public: boolean + is_default: boolean version: number usage_count: number created_at: string @@ -86,13 +99,33 @@ export interface TreeCreate { name: string description?: string category?: string + category_id?: string | null + tags?: string[] tree_structure: TreeStructure + is_public?: boolean + is_default?: boolean } export interface TreeUpdate { name?: string description?: string category?: string + category_id?: string | null + tags?: string[] tree_structure?: TreeStructure is_active?: boolean + is_public?: boolean +} + +// Filter params for tree listing +export interface TreeFilters { + category?: string + category_id?: string + tags?: string + folder_id?: string + is_active?: boolean + author_id?: string + is_public?: boolean + skip?: number + limit?: number } diff --git a/frontend/src/types/user.ts b/frontend/src/types/user.ts index 7fdf490e..f438406e 100644 --- a/frontend/src/types/user.ts +++ b/frontend/src/types/user.ts @@ -5,6 +5,7 @@ export interface User { email: string name: string role: UserRole + is_team_admin: boolean team_id: string | null created_at: string last_login: string | null