Add tree organization system with categories, tags, and folders

Features:
- Categories: Global and team-specific tree categorization (admin-managed)
- Tags: Flexible tree tagging with autocomplete (author + admin)
- User folders: Personal tree collections with subfolder support
  - Hierarchical structure (max 3 levels deep)
  - Right-click context menu for folder management
  - Cascade delete for subfolders
- Filter trees by category, tags, and folder in library view

Backend:
- New models: Category, Tag, UserFolder with relationships
- New API endpoints for categories, tags, and folders
- Tree organization migrations (005, 006)

Frontend:
- FolderSidebar with hierarchical folder tree
- FolderEditModal for create/edit with color picker
- AddToFolderMenu for quick tree organization
- TagInput with autocomplete and TagBadges display
- Updated TreeMetadataForm and TreeLibraryPage

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
chihlasm
2026-02-02 01:31:13 -05:00
parent 2d99c52025
commit fafdaa50a5
41 changed files with 5006 additions and 221 deletions

563
CLAUDE.md Normal file
View File

@@ -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<TreeStructure | null>(null)
// CORRECT - Store ID only, fetch current object each render
const [editingNodeId, setEditingNodeId] = useState<string | null>(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 `<span>` 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 <noreply@anthropic.com>`
---
## 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

View File

@@ -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')

View File

@@ -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')

View File

@@ -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

View File

@@ -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]

View File

@@ -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]

View File

@@ -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)

View File

@@ -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)

View File

@@ -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",
]

View File

@@ -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

View File

@@ -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

86
backend/app/models/tag.py Normal file
View File

@@ -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

View File

@@ -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")

View File

@@ -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 []

View File

@@ -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")
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)

View File

@@ -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",
]

View File

@@ -0,0 +1,58 @@
from datetime import datetime
from typing import Optional
from uuid import UUID
from pydantic import BaseModel, Field
import re
def slugify(name: str) -> str:
"""Convert a name to a URL-safe slug."""
# Remove non-alphanumeric chars except spaces, convert to lowercase
slug = re.sub(r'[^a-zA-Z0-9 ]', '', name.lower())
# Replace spaces with hyphens
slug = re.sub(r' +', '-', slug.strip())
return slug
class 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

View File

@@ -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

View File

@@ -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")

View File

@@ -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

View File

@@ -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

View File

@@ -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<CategoryListItem[]> {
const response = await apiClient.get<CategoryListItem[]>('/categories', {
params: { include_inactive: includeInactive, team_only: teamOnly },
})
return response.data
},
async get(id: string): Promise<Category> {
const response = await apiClient.get<Category>(`/categories/${id}`)
return response.data
},
async create(data: CategoryCreate): Promise<Category> {
const response = await apiClient.post<Category>('/categories', data)
return response.data
},
async update(id: string, data: CategoryUpdate): Promise<Category> {
const response = await apiClient.put<Category>(`/categories/${id}`, data)
return response.data
},
async delete(id: string): Promise<void> {
await apiClient.delete(`/categories/${id}`)
},
}
export default categoriesApi

View File

@@ -0,0 +1,50 @@
import apiClient from './client'
import type { Folder, FolderListItem, FolderCreate, FolderUpdate, FolderReorderRequest } from '@/types'
export const foldersApi = {
async list(): Promise<FolderListItem[]> {
const response = await apiClient.get<FolderListItem[]>('/folders')
return response.data
},
async get(id: string): Promise<Folder> {
const response = await apiClient.get<Folder>(`/folders/${id}`)
return response.data
},
async create(data: FolderCreate): Promise<Folder> {
const response = await apiClient.post<Folder>('/folders', data)
return response.data
},
async update(id: string, data: FolderUpdate): Promise<Folder> {
const response = await apiClient.put<Folder>(`/folders/${id}`, data)
return response.data
},
async delete(id: string): Promise<void> {
await apiClient.delete(`/folders/${id}`)
},
async reorder(folderIds: string[]): Promise<void> {
await apiClient.post('/folders/reorder', {
folder_ids: folderIds,
} as FolderReorderRequest)
},
// Folder tree management
async getTreeIds(folderId: string): Promise<string[]> {
const response = await apiClient.get<string[]>(`/folders/${folderId}/trees`)
return response.data
},
async addTree(folderId: string, treeId: string): Promise<void> {
await apiClient.post(`/folders/${folderId}/trees`, { tree_id: treeId })
},
async removeTree(folderId: string, treeId: string): Promise<void> {
await apiClient.delete(`/folders/${folderId}/trees/${treeId}`)
},
}
export default foldersApi

View File

@@ -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'

54
frontend/src/api/tags.ts Normal file
View File

@@ -0,0 +1,54 @@
import apiClient from './client'
import type { Tag, TagListItem, TagCreate, TagAssignment } from '@/types'
export const tagsApi = {
async list(includeTeam = true): Promise<TagListItem[]> {
const response = await apiClient.get<TagListItem[]>('/tags', {
params: { include_team: includeTeam },
})
return response.data
},
async search(query: string, limit = 10, includeTeam = true): Promise<TagListItem[]> {
const response = await apiClient.get<TagListItem[]>('/tags/search', {
params: { q: query, limit, include_team: includeTeam },
})
return response.data
},
async get(id: string): Promise<Tag> {
const response = await apiClient.get<Tag>(`/tags/${id}`)
return response.data
},
async create(data: TagCreate): Promise<Tag> {
const response = await apiClient.post<Tag>('/tags', data)
return response.data
},
// Tree tag management
async getTreeTags(treeId: string): Promise<TagListItem[]> {
const response = await apiClient.get<TagListItem[]>(`/tags/trees/${treeId}`)
return response.data
},
async addTagsToTree(treeId: string, tags: string[]): Promise<TagListItem[]> {
const response = await apiClient.post<TagListItem[]>(`/tags/trees/${treeId}`, {
tags,
} as TagAssignment)
return response.data
},
async replaceTreeTags(treeId: string, tags: string[]): Promise<TagListItem[]> {
const response = await apiClient.put<TagListItem[]>(`/tags/trees/${treeId}`, {
tags,
} as TagAssignment)
return response.data
},
async removeTagFromTree(treeId: string, tagSlug: string): Promise<void> {
await apiClient.delete(`/tags/trees/${treeId}/${tagSlug}`)
},
}
export default tagsApi

View File

@@ -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<TreeListItem[]> {
async list(params?: TreeFilters): Promise<TreeListItem[]> {
const response = await apiClient.get<TreeListItem[]>('/trees', { params })
return response.data
},
@@ -41,14 +26,15 @@ export const treesApi = {
await apiClient.delete(`/trees/${id}`)
},
async categories(): Promise<string[]> {
// Legacy categories endpoint (returns string categories)
async legacyCategories(): Promise<string[]> {
const response = await apiClient.get<string[]>('/trees/categories')
return response.data
},
async search(query: string, category?: string): Promise<TreeListItem[]> {
async search(query: string, limit?: number): Promise<TreeListItem[]> {
const response = await apiClient.get<TreeListItem[]>('/trees/search', {
params: { q: query, category },
params: { q: query, limit },
})
return response.data
},

View File

@@ -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 (
<div className="flex flex-wrap items-center gap-1">
{visibleTags.map((tag) => (
<button
key={tag}
type="button"
onClick={(e) => {
if (onTagClick) {
e.stopPropagation()
onTagClick(tag)
}
}}
disabled={!onTagClick}
className={cn(
'rounded-full transition-colors',
size === 'sm' ? 'px-2 py-0.5 text-xs' : 'px-2.5 py-1 text-sm',
variant === 'default'
? 'bg-primary/10 text-primary hover:bg-primary/20'
: 'bg-muted text-muted-foreground hover:bg-muted/80',
!onTagClick && 'cursor-default'
)}
>
{tag}
</button>
))}
{hiddenCount > 0 && (
<span
className={cn(
'rounded-full',
size === 'sm' ? 'px-2 py-0.5 text-xs' : 'px-2.5 py-1 text-sm',
'bg-muted text-muted-foreground'
)}
title={tags.slice(maxVisible).join(', ')}
>
+{hiddenCount} more
</span>
)}
</div>
)
}
export default TagBadges

View File

@@ -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<TagListItem[]>([])
const [showSuggestions, setShowSuggestions] = useState(false)
const [selectedIndex, setSelectedIndex] = useState(-1)
const inputRef = useRef<HTMLInputElement>(null)
const wrapperRef = useRef<HTMLDivElement>(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<HTMLInputElement>) => {
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 (
<div ref={wrapperRef} className="relative">
<div
className={cn(
'flex flex-wrap gap-1.5 rounded-md border px-2 py-1.5',
'bg-background text-foreground',
'focus-within:border-primary focus-within:ring-1 focus-within:ring-primary',
disabled ? 'cursor-not-allowed opacity-50' : '',
'border-input'
)}
onClick={() => inputRef.current?.focus()}
>
{/* Tag chips */}
{tags.map((tag) => (
<span
key={tag}
className={cn(
'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs',
'bg-primary/10 text-primary'
)}
>
{tag}
{!disabled && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
removeTag(tag)
}}
className="rounded-full p-0.5 hover:bg-primary/20"
>
<X className="h-3 w-3" />
</button>
)}
</span>
))}
{/* Input field */}
{tags.length < maxTags && (
<input
ref={inputRef}
type="text"
value={inputValue}
onChange={(e) => 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'
)}
/>
)}
</div>
{/* Suggestions dropdown */}
{showSuggestions && suggestions.length > 0 && (
<div
className={cn(
'absolute z-10 mt-1 w-full rounded-md border border-input',
'bg-popover shadow-lg'
)}
>
{suggestions.map((suggestion, index) => (
<button
key={suggestion.id}
type="button"
onClick={() => addTag(suggestion.name)}
className={cn(
'flex w-full items-center justify-between px-3 py-2 text-sm',
'hover:bg-accent',
index === selectedIndex && 'bg-accent'
)}
>
<span>{suggestion.name}</span>
<span className="text-xs text-muted-foreground">
{suggestion.usage_count} trees
</span>
</button>
))}
{inputValue.trim() &&
!suggestions.some(
(s) => s.name.toLowerCase() === inputValue.toLowerCase()
) && (
<button
type="button"
onClick={() => addTag(inputValue)}
className={cn(
'flex w-full items-center gap-2 border-t border-input px-3 py-2 text-sm',
'hover:bg-accent text-primary'
)}
>
<Plus className="h-4 w-4" />
Create "{inputValue}"
</button>
)}
</div>
)}
{/* Helper text */}
<p className="mt-1 text-xs text-muted-foreground">
{tags.length}/{maxTags} tags. Press Enter or comma to add.
</p>
</div>
)
}
export default TagInput

View File

@@ -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<FolderListItem[]>([])
const [treeFolderIds, setTreeFolderIds] = useState<Set<string>>(new Set())
const [isLoading, setIsLoading] = useState(false)
const menuRef = useRef<HTMLDivElement>(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<string>()
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 (
<div ref={menuRef} className="relative">
<button
onClick={(e) => {
e.stopPropagation()
setIsOpen(!isOpen)
}}
className={cn(
'rounded-md border border-input p-1.5 text-muted-foreground',
'hover:bg-accent hover:text-accent-foreground'
)}
title="Add to folder"
>
<FolderPlus className="h-4 w-4" />
</button>
{isOpen && (
<div
className={cn(
'absolute right-0 top-full z-20 mt-1 w-48 rounded-md border border-input',
'bg-popover py-1 shadow-lg'
)}
>
{isLoading ? (
<div className="px-3 py-2 text-sm text-muted-foreground">Loading...</div>
) : folders.length === 0 ? (
<div className="px-3 py-2 text-sm text-muted-foreground">No folders yet</div>
) : (
folders.map((folder) => (
<button
key={folder.id}
onClick={(e) => {
e.stopPropagation()
toggleFolder(folder.id)
}}
className="flex w-full items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent"
>
<div
className="h-3 w-3 rounded-sm"
style={{ backgroundColor: folder.color }}
/>
<span className="flex-1 truncate text-left">{folder.name}</span>
{treeFolderIds.has(folder.id) && (
<Check className="h-4 w-4 text-primary" />
)}
</button>
))
)}
<div className="border-t border-input my-1" />
<button
onClick={(e) => {
e.stopPropagation()
setIsOpen(false)
onFolderCreated?.()
}}
className="flex w-full items-center gap-2 px-3 py-1.5 text-sm text-primary hover:bg-accent"
>
<Plus className="h-4 w-4" />
Create new folder
</button>
</div>
)}
</div>
)
}
export default AddToFolderMenu

View File

@@ -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<string> {
const descendants = new Set<string>()
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<string | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState<string | null>(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<string>()
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 (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div className="absolute inset-0 bg-background/80 backdrop-blur-sm" onClick={onClose} />
{/* Modal */}
<div className="relative z-10 w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-lg">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-card-foreground">
{isEditMode ? 'Edit Folder' : initialParentId ? 'Create Subfolder' : 'Create Folder'}
</h2>
<button onClick={onClose} className="rounded-md p-1 hover:bg-accent">
<X className="h-5 w-5" />
</button>
</div>
<form onSubmit={handleSubmit}>
{/* Name input */}
<div className="mb-4">
<label htmlFor="folder-name" className="block text-sm font-medium text-foreground">
Name
</label>
<input
id="folder-name"
type="text"
value={name}
onChange={(e) => 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
/>
</div>
{/* Parent folder dropdown */}
<div className="mb-4">
<label htmlFor="folder-parent" className="block text-sm font-medium text-foreground">
Parent Folder
</label>
<select
id="folder-parent"
value={parentId || ''}
onChange={(e) => setParentId(e.target.value || null)}
className={cn(
'mt-1 block w-full rounded-md border px-3 py-2 text-sm',
'bg-background text-foreground',
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary',
'border-input'
)}
>
<option value="">None (root level)</option>
{parentOptions.map((f) => (
<option key={f.id} value={f.id}>
{getIndentedName(folders, f.id)}
</option>
))}
</select>
<p className="mt-1 text-xs text-muted-foreground">
Folders can be nested up to 3 levels deep.
</p>
</div>
{/* Color picker */}
<div className="mb-6">
<label className="block text-sm font-medium text-foreground">Color</label>
<div className="mt-2 flex flex-wrap gap-2">
{FOLDER_COLORS.map((c) => (
<button
key={c}
type="button"
onClick={() => setColor(c)}
className={cn(
'h-8 w-8 rounded-full transition-transform',
color === c && 'ring-2 ring-offset-2 ring-offset-background ring-primary scale-110'
)}
style={{ backgroundColor: c }}
title={c}
/>
))}
</div>
</div>
{/* Error message */}
{error && (
<div className="mb-4 rounded-md bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
)}
{/* Actions */}
<div className="flex justify-end gap-3">
<button
type="button"
onClick={onClose}
className={cn('rounded-md border border-input px-4 py-2 text-sm', 'hover:bg-accent')}
>
Cancel
</button>
<button
type="submit"
disabled={isSubmitting}
className={cn(
'rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground',
'hover:bg-primary/90',
'disabled:opacity-50'
)}
>
{isSubmitting ? 'Saving...' : isEditMode ? 'Save Changes' : 'Create Folder'}
</button>
</div>
</form>
</div>
</div>
)
}
export default FolderEditModal

View File

@@ -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<string, FolderTreeItem>()
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<string>
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 (
<div>
<div className="group relative" onContextMenu={handleContextMenu}>
<button
onClick={() => onFolderSelect(folder.id)}
className={cn(
'flex w-full items-center gap-1 rounded-md py-1.5 text-sm',
'transition-colors hover:bg-accent',
selectedFolderId === folder.id && 'bg-accent font-medium'
)}
style={{ paddingLeft: `${8 + depth * 16}px`, paddingRight: '8px' }}
>
{/* Expand/collapse toggle */}
{hasSubfolders ? (
<button
onClick={(e) => {
e.stopPropagation()
onToggleExpand(folder.id)
}}
className="shrink-0 p-0.5 hover:bg-accent rounded"
>
{isExpanded ? (
<ChevronDown className="h-3 w-3" />
) : (
<ChevronRight className="h-3 w-3" />
)}
</button>
) : (
<span className="w-4" /> // Spacer for alignment
)}
<Folder className="h-4 w-4 shrink-0" style={{ color: folder.color }} />
<span className="flex-1 truncate text-left">{folder.name}</span>
<span className="text-xs text-muted-foreground">{folder.tree_count}</span>
</button>
{/* Folder menu button */}
<button
onClick={(e) => {
e.stopPropagation()
onMenuToggle(menuOpenId === folder.id ? null : folder.id)
}}
className={cn(
'absolute right-1 top-1/2 -translate-y-1/2 rounded p-1',
'opacity-0 group-hover:opacity-100',
'hover:bg-accent'
)}
>
<MoreVertical className="h-3 w-3" />
</button>
{/* Dropdown menu */}
{menuOpenId === folder.id && (
<div
className={cn(
'absolute right-0 top-full z-10 mt-1 w-40 rounded-md border border-input',
'bg-popover py-1 shadow-lg'
)}
>
<button
onClick={(e) => {
e.stopPropagation()
onEditFolder(folder)
onMenuToggle(null)
}}
className="flex w-full items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent"
>
<Pencil className="h-3 w-3" />
Edit
</button>
{canAddSubfolder && (
<button
onClick={(e) => {
e.stopPropagation()
onAddSubfolder(folder.id)
onMenuToggle(null)
}}
className="flex w-full items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent"
>
<FolderPlus className="h-3 w-3" />
Add Subfolder
</button>
)}
<button
onClick={(e) => {
e.stopPropagation()
onDeleteFolder(folder.id, hasSubfolders)
onMenuToggle(null)
}}
className="flex w-full items-center gap-2 px-3 py-1.5 text-sm text-destructive hover:bg-accent"
>
<Trash2 className="h-3 w-3" />
Delete
</button>
</div>
)}
</div>
{/* Children */}
{isExpanded && hasSubfolders && (
<div>
{folder.children.map((child) => (
<FolderItem
key={child.id}
folder={child}
depth={depth + 1}
selectedFolderId={selectedFolderId}
expandedIds={expandedIds}
menuOpenId={menuOpenId}
onFolderSelect={onFolderSelect}
onToggleExpand={onToggleExpand}
onMenuToggle={onMenuToggle}
onEditFolder={onEditFolder}
onAddSubfolder={onAddSubfolder}
onDeleteFolder={onDeleteFolder}
onContextMenu={onContextMenu}
canAddSubfolder={depth + 1 < 2} // Max depth is 3, so can add at depth 0, 1 (not 2)
/>
))}
</div>
)}
</div>
)
}
export function FolderSidebar({
selectedFolderId,
onFolderSelect,
onCreateFolder,
onEditFolder,
}: FolderSidebarProps) {
const [folders, setFolders] = useState<FolderListItem[]>([])
const [folderTree, setFolderTree] = useState<FolderTreeItem[]>([])
const [isExpanded, setIsExpanded] = useState(true)
const [isLoading, setIsLoading] = useState(true)
const [menuOpenId, setMenuOpenId] = useState<string | null>(null)
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set())
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(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 (
<>
<div className="w-56 shrink-0 border-r border-border bg-card">
<div className="p-4">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="flex w-full items-center gap-2 text-sm font-medium text-card-foreground"
>
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
<span>FOLDERS</span>
</button>
{isExpanded && (
<div className="mt-3 space-y-0.5">
{/* All Trees */}
<button
onClick={() => onFolderSelect(null)}
className={cn(
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm',
'transition-colors hover:bg-accent',
selectedFolderId === null && 'bg-accent font-medium'
)}
>
<Folder className="h-4 w-4" />
<span>All Trees</span>
</button>
{/* Loading state */}
{isLoading ? (
<div className="px-2 py-1.5 text-sm text-muted-foreground">Loading...</div>
) : (
<>
{/* User folders (hierarchical) */}
{folderTree.map((folder) => (
<FolderItem
key={folder.id}
folder={folder}
depth={0}
selectedFolderId={selectedFolderId}
expandedIds={expandedIds}
menuOpenId={menuOpenId}
onFolderSelect={onFolderSelect}
onToggleExpand={handleToggleExpand}
onMenuToggle={setMenuOpenId}
onEditFolder={onEditFolder}
onAddSubfolder={handleAddSubfolder}
onDeleteFolder={handleDeleteFolder}
onContextMenu={handleContextMenu}
canAddSubfolder={true} // Root folders can have subfolders
/>
))}
</>
)}
{/* Create folder button */}
<button
onClick={() => onCreateFolder(null)}
className={cn(
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm',
'text-muted-foreground transition-colors hover:bg-accent hover:text-foreground'
)}
>
<Plus className="h-4 w-4" />
<span>New Folder</span>
</button>
</div>
)}
</div>
</div>
{/* Right-click context menu */}
{contextMenu && (
<div
className={cn(
'fixed z-50 w-44 rounded-md border border-input',
'bg-popover py-1 shadow-lg'
)}
style={{ left: contextMenu.x, top: contextMenu.y }}
onClick={(e) => e.stopPropagation()}
>
<button
onClick={() => {
onEditFolder(contextMenu.folder)
closeContextMenu()
}}
className="flex w-full items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent"
>
<Pencil className="h-3 w-3" />
Edit
</button>
{contextMenu.canAddSubfolder && (
<button
onClick={() => {
handleAddSubfolder(contextMenu.folder.id)
closeContextMenu()
}}
className="flex w-full items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent"
>
<FolderPlus className="h-3 w-3" />
Add Subfolder
</button>
)}
<button
onClick={() => {
handleDeleteFolder(contextMenu.folder.id, contextMenu.folder.children.length > 0)
closeContextMenu()
}}
className="flex w-full items-center gap-2 px-3 py-1.5 text-sm text-destructive hover:bg-accent"
>
<Trash2 className="h-3 w-3" />
Delete
</button>
</div>
)}
</>
)
}
export default FolderSidebar

View File

@@ -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<string[]>([])
const [categories, setCategories] = useState<CategoryListItem[]>([])
const [legacyCategories, setLegacyCategories] = useState<string[]>([])
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 && (
<p className="mt-1 text-xs text-destructive">{nameError.message}</p>
)}
{nameError && <p className="mt-1 text-xs text-destructive">{nameError.message}</p>}
</div>
{/* Description */}
@@ -83,7 +108,7 @@ export function TreeMetadataForm() {
{!customCategory ? (
<select
id="tree-category"
value={category || ''}
value={categoryId || ''}
onChange={(e) => handleCategoryChange(e.target.value)}
className={cn(
'mt-1 block w-full rounded-md border border-input px-3 py-2 text-sm',
@@ -93,8 +118,9 @@ export function TreeMetadataForm() {
>
<option value="">No category</option>
{categories.map((cat) => (
<option key={cat} value={cat}>
{cat}
<option key={cat.id} value={cat.id}>
{cat.name}
{cat.team_id ? ' (Team)' : ''}
</option>
))}
<option value="__custom__">+ Add custom category</option>
@@ -117,6 +143,7 @@ export function TreeMetadataForm() {
onClick={() => {
setCustomCategory(false)
setCategory('')
setCategoryId(null)
}}
className="rounded-md border border-input px-3 py-2 text-sm hover:bg-accent"
>
@@ -125,6 +152,60 @@ export function TreeMetadataForm() {
</div>
)}
</div>
{/* Tags */}
<div>
<label className="block text-sm font-medium text-foreground">Tags</label>
<div className="mt-1">
<TagInput tags={tags} onChange={setTags} maxTags={10} placeholder="Add tags..." />
</div>
</div>
{/* Visibility */}
<div>
<label className="block text-sm font-medium text-foreground">Visibility</label>
<div className="mt-2 flex gap-4">
<label
className={cn(
'flex cursor-pointer items-center gap-2 rounded-md border px-4 py-2',
'transition-colors',
!isPublic ? 'border-primary bg-primary/10' : 'border-input hover:bg-accent'
)}
>
<input
type="radio"
name="visibility"
checked={!isPublic}
onChange={() => setIsPublic(false)}
className="sr-only"
/>
<Lock className="h-4 w-4" />
<span className="text-sm">Private</span>
</label>
<label
className={cn(
'flex cursor-pointer items-center gap-2 rounded-md border px-4 py-2',
'transition-colors',
isPublic ? 'border-primary bg-primary/10' : 'border-input hover:bg-accent'
)}
>
<input
type="radio"
name="visibility"
checked={isPublic}
onChange={() => setIsPublic(true)}
className="sr-only"
/>
<Globe className="h-4 w-4" />
<span className="text-sm">Public</span>
</label>
</div>
<p className="mt-1 text-xs text-muted-foreground">
{isPublic
? 'Anyone can view this tree'
: 'Only you and your team can view this tree'}
</p>
</div>
</div>
)
}

View File

@@ -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.')

View File

@@ -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<TreeListItem[]>([])
const [categories, setCategories] = useState<string[]>([])
const [selectedCategory, setSelectedCategory] = useState<string>('')
const [categories, setCategories] = useState<CategoryListItem[]>([])
const [folders, setFolders] = useState<FolderListItem[]>([])
const [selectedCategoryId, setSelectedCategoryId] = useState<string>('')
const [selectedTags, setSelectedTags] = useState<string[]>([])
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null)
const [searchQuery, setSearchQuery] = useState('')
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Folder modal state
const [folderModalOpen, setFolderModalOpen] = useState(false)
const [editingFolder, setEditingFolder] = useState<FolderListItem | null>(null)
const [newFolderParentId, setNewFolderParentId] = useState<string | null>(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 (
<div className="container mx-auto px-4 py-8">
<div className="mb-8 flex items-start justify-between">
<div>
<h1 className="text-3xl font-bold text-foreground">Decision Trees</h1>
<p className="mt-2 text-muted-foreground">
Select a troubleshooting tree to start a new session
</p>
</div>
<Link
to="/trees/new"
className={cn(
'flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground',
'hover:bg-primary/90'
)}
>
<Plus className="h-4 w-4" />
Create Tree
</Link>
</div>
<div className="flex h-[calc(100vh-4rem)]">
{/* Folder Sidebar */}
<FolderSidebar
selectedFolderId={selectedFolderId}
onFolderSelect={setSelectedFolderId}
onCreateFolder={handleCreateFolder}
onEditFolder={handleEditFolder}
/>
{/* Search and Filter */}
<div className="mb-6 flex flex-col gap-4 sm:flex-row">
<div className="flex flex-1 gap-2">
<input
type="text"
placeholder="Search trees..."
value={searchQuery}
onChange={(e) => 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'
)}
/>
<button
onClick={handleSearch}
className={cn(
'rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground',
'hover:bg-primary/90'
)}
>
Search
</button>
</div>
<select
value={selectedCategory}
onChange={(e) => setSelectedCategory(e.target.value)}
aria-label="Filter by category"
className={cn(
'rounded-md border border-input bg-background px-3 py-2',
'text-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary'
)}
>
<option value="">All Categories</option>
{categories.map((cat) => (
<option key={cat} value={cat}>
{cat}
</option>
))}
</select>
</div>
{/* Error State */}
{error && (
<div className="mb-6 rounded-md bg-destructive/10 p-4 text-destructive">
{error}
</div>
)}
{/* Loading State */}
{isLoading ? (
<div className="flex justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
) : trees.length === 0 ? (
<div className="py-12 text-center text-muted-foreground">
No trees found. {searchQuery && 'Try adjusting your search.'}
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{trees.map((tree) => (
<div
key={tree.id}
className="rounded-lg border border-border bg-card p-6 shadow-sm transition-shadow hover:shadow-md"
>
<div className="mb-2 flex items-start justify-between">
<h3 className="font-semibold text-card-foreground">{tree.name}</h3>
{tree.category && (
<span className="rounded-full bg-secondary px-2 py-0.5 text-xs text-secondary-foreground">
{tree.category}
</span>
)}
</div>
<p className="mb-4 text-sm text-muted-foreground line-clamp-2">
{tree.description || 'No description available'}
{/* Main Content */}
<div className="flex-1 overflow-auto">
<div className="container mx-auto px-4 py-8">
<div className="mb-8 flex items-start justify-between">
<div>
<h1 className="text-3xl font-bold text-foreground">Decision Trees</h1>
<p className="mt-2 text-muted-foreground">
Select a troubleshooting tree to start a new session
</p>
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">
v{tree.version} · {tree.usage_count} uses
</span>
<div className="flex items-center gap-2">
<Link
to={`/trees/${tree.id}/edit`}
className={cn(
'rounded-md border border-input p-1.5 text-muted-foreground',
'hover:bg-accent hover:text-accent-foreground'
)}
title="Edit tree"
>
<Pencil className="h-4 w-4" />
</Link>
<button
type="button"
onClick={() => handleStartSession(tree.id)}
className={cn(
'rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground',
'hover:bg-primary/90'
)}
>
Start Session
</button>
</div>
</div>
</div>
))}
<Link
to="/trees/new"
className={cn(
'flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground',
'hover:bg-primary/90'
)}
>
<Plus className="h-4 w-4" />
Create Tree
</Link>
</div>
{/* Search and Filter */}
<div className="mb-4 flex flex-col gap-4 sm:flex-row">
<div className="flex flex-1 gap-2">
<input
type="text"
placeholder="Search trees..."
value={searchQuery}
onChange={(e) => 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'
)}
/>
<button
onClick={handleSearch}
className={cn(
'rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground',
'hover:bg-primary/90'
)}
>
Search
</button>
</div>
<select
value={selectedCategoryId}
onChange={(e) => setSelectedCategoryId(e.target.value)}
aria-label="Filter by category"
className={cn(
'rounded-md border border-input bg-background px-3 py-2',
'text-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary'
)}
>
<option value="">All Categories</option>
{categories.map((cat) => (
<option key={cat.id} value={cat.id}>
{cat.name} ({cat.tree_count})
</option>
))}
</select>
</div>
{/* Active Filters */}
{hasActiveFilters && (
<div className="mb-6 flex flex-wrap items-center gap-2">
<span className="text-sm text-muted-foreground">Filters:</span>
{selectedFolderId && (
<span className="inline-flex items-center gap-1 rounded-full bg-accent px-3 py-1 text-sm">
Folder
<button
onClick={() => setSelectedFolderId(null)}
className="rounded-full p-0.5 hover:bg-accent-foreground/10"
>
<X className="h-3 w-3" />
</button>
</span>
)}
{selectedCategoryId && (
<span className="inline-flex items-center gap-1 rounded-full bg-secondary px-3 py-1 text-sm">
{categories.find((c) => c.id === selectedCategoryId)?.name}
<button
onClick={() => setSelectedCategoryId('')}
className="rounded-full p-0.5 hover:bg-secondary-foreground/10"
>
<X className="h-3 w-3" />
</button>
</span>
)}
{selectedTags.map((tag) => (
<span
key={tag}
className="inline-flex items-center gap-1 rounded-full bg-primary/10 px-3 py-1 text-sm text-primary"
>
{tag}
<button
onClick={() => removeTagFilter(tag)}
className="rounded-full p-0.5 hover:bg-primary/20"
>
<X className="h-3 w-3" />
</button>
</span>
))}
<button
onClick={clearAllFilters}
className="text-sm text-muted-foreground hover:text-foreground"
>
Clear all
</button>
</div>
)}
{/* Error State */}
{error && (
<div className="mb-6 rounded-md bg-destructive/10 p-4 text-destructive">{error}</div>
)}
{/* Loading State */}
{isLoading ? (
<div className="flex justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
) : trees.length === 0 ? (
<div className="py-12 text-center text-muted-foreground">
No trees found.{' '}
{(searchQuery || hasActiveFilters) && 'Try adjusting your filters.'}
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{trees.map((tree) => (
<div
key={tree.id}
className="rounded-lg border border-border bg-card p-6 shadow-sm transition-shadow hover:shadow-md"
>
<div className="mb-2 flex items-start justify-between gap-2">
<h3 className="font-semibold text-card-foreground">{tree.name}</h3>
<div className="flex items-center gap-2">
{tree.is_public ? (
<Globe className="h-4 w-4 text-muted-foreground" title="Public tree" />
) : (
<Lock className="h-4 w-4 text-muted-foreground" title="Private tree" />
)}
{tree.category_info && (
<span className="rounded-full bg-secondary px-2 py-0.5 text-xs text-secondary-foreground">
{tree.category_info.name}
</span>
)}
</div>
</div>
<p className="mb-3 text-sm text-muted-foreground line-clamp-2">
{tree.description || 'No description available'}
</p>
{/* Tags */}
{tree.tags && tree.tags.length > 0 && (
<div className="mb-3">
<TagBadges tags={tree.tags} maxVisible={3} onTagClick={handleTagClick} />
</div>
)}
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">
v{tree.version} · {tree.usage_count} uses
</span>
<div className="flex items-center gap-2">
<AddToFolderMenu treeId={tree.id} onFolderCreated={handleCreateFolder} />
<Link
to={`/trees/${tree.id}/edit`}
className={cn(
'rounded-md border border-input p-1.5 text-muted-foreground',
'hover:bg-accent hover:text-accent-foreground'
)}
title="Edit tree"
>
<Pencil className="h-4 w-4" />
</Link>
<button
type="button"
onClick={() => handleStartSession(tree.id)}
className={cn(
'rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground',
'hover:bg-primary/90'
)}
>
Start Session
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
{/* Folder Edit Modal */}
<FolderEditModal
folder={editingFolder}
parentId={newFolderParentId}
folders={folders}
isOpen={folderModalOpen}
onClose={() => {
setFolderModalOpen(false)
setNewFolderParentId(null)
}}
onSave={loadData}
/>
</div>
)
}

View File

@@ -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<TreeEditorState>()(
name: '',
description: '',
category: '',
categoryId: null,
tags: [],
isPublic: false,
treeStructure: null,
originalTree: null,
selectedNodeId: null,
@@ -188,6 +199,9 @@ export const useTreeEditorStore = create<TreeEditorState>()(
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<TreeEditorState>()(
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<TreeEditorState>()(
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<TreeEditorState>()(
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<TreeEditorState>()(
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<TreeEditorState>()(
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<TreeEditorState>()(
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<TreeEditorState>()(
name: state.name,
description: state.description,
category: state.category,
categoryId: state.categoryId,
tags: state.tags,
isPublic: state.isPublic,
treeStructure: state.treeStructure
})
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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<T> {

27
frontend/src/types/tag.ts Normal file
View File

@@ -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[]
}

View File

@@ -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
}

View File

@@ -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