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:
563
CLAUDE.md
Normal file
563
CLAUDE.md
Normal 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
|
||||||
193
backend/alembic/versions/005_add_tree_organization.py
Normal file
193
backend/alembic/versions/005_add_tree_organization.py
Normal 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')
|
||||||
54
backend/alembic/versions/006_add_folder_hierarchy.py
Normal file
54
backend/alembic/versions/006_add_folder_hierarchy.py
Normal 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')
|
||||||
314
backend/app/api/endpoints/categories.py
Normal file
314
backend/app/api/endpoints/categories.py
Normal 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
|
||||||
549
backend/app/api/endpoints/folders.py
Normal file
549
backend/app/api/endpoints/folders.py
Normal 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]
|
||||||
437
backend/app/api/endpoints/tags.py
Normal file
437
backend/app/api/endpoints/tags.py
Normal 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]
|
||||||
@@ -3,53 +3,173 @@ from uuid import UUID
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select, func, or_
|
from sqlalchemy import select, func, or_
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.models.tree import Tree
|
from app.models.tree import Tree
|
||||||
from app.models.user import User
|
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
|
from app.api.deps import get_current_user, require_engineer_or_admin, require_admin
|
||||||
|
|
||||||
router = APIRouter(prefix="/trees", tags=["trees"])
|
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])
|
@router.get("", response_model=list[TreeListResponse])
|
||||||
async def list_trees(
|
async def list_trees(
|
||||||
db: Annotated[AsyncSession, Depends(get_db)],
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
current_user: Annotated[User, Depends(get_current_user)],
|
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"),
|
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),
|
skip: int = Query(0, ge=0),
|
||||||
limit: int = Query(100, ge=1, le=100)
|
limit: int = Query(100, ge=1, le=100)
|
||||||
):
|
):
|
||||||
"""List all trees with optional filters."""
|
"""List all trees with optional filters.
|
||||||
query = select(Tree)
|
|
||||||
|
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
|
# Apply filters
|
||||||
if category:
|
if category:
|
||||||
query = query.where(Tree.category == category)
|
query = query.where(Tree.category == category)
|
||||||
|
if category_id:
|
||||||
|
query = query.where(Tree.category_id == category_id)
|
||||||
if is_active is not None:
|
if is_active is not None:
|
||||||
query = query.where(Tree.is_active == is_active)
|
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:
|
# Filter by tags (all specified tags must be present)
|
||||||
# - Default/system trees (visible to all)
|
if tags:
|
||||||
# - Public trees
|
tag_slugs = [t.strip() for t in tags.split(",") if t.strip()]
|
||||||
# - User's own trees (public or private)
|
for tag_slug in tag_slugs:
|
||||||
query = query.where(
|
query = query.where(
|
||||||
Tree.is_active == True,
|
Tree.tags.any(TreeTag.slug == tag_slug)
|
||||||
or_(
|
)
|
||||||
Tree.is_default == True,
|
|
||||||
Tree.is_public == True,
|
# Filter by folder
|
||||||
Tree.author_id == current_user.id
|
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.order_by(Tree.usage_count.desc(), Tree.updated_at.desc())
|
||||||
query = query.offset(skip).limit(limit)
|
query = query.offset(skip).limit(limit)
|
||||||
|
|
||||||
result = await db.execute(query)
|
result = await db.execute(query)
|
||||||
trees = result.scalars().all()
|
trees = result.scalars().unique().all()
|
||||||
return trees
|
|
||||||
|
return [build_tree_response(tree) for tree in trees]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/categories", response_model=list[str])
|
@router.get("/categories", response_model=list[str])
|
||||||
@@ -57,15 +177,15 @@ async def list_categories(
|
|||||||
db: Annotated[AsyncSession, Depends(get_db)],
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
current_user: Annotated[User, Depends(get_current_user)]
|
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(
|
query = select(Tree.category).where(
|
||||||
Tree.category.isnot(None),
|
Tree.category.isnot(None),
|
||||||
Tree.is_active == True,
|
Tree.is_active == True,
|
||||||
or_(
|
build_tree_access_filter(current_user)
|
||||||
Tree.is_default == True,
|
|
||||||
Tree.is_public == True,
|
|
||||||
Tree.author_id == current_user.id
|
|
||||||
)
|
|
||||||
).distinct()
|
).distinct()
|
||||||
result = await db.execute(query)
|
result = await db.execute(query)
|
||||||
categories = [row[0] for row in result.all() if row[0]]
|
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_vector = func.to_tsvector('english', func.coalesce(Tree.name, '') + ' ' + func.coalesce(Tree.description, ''))
|
||||||
search_query = func.plainto_tsquery('english', q)
|
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,
|
Tree.is_active == True,
|
||||||
or_(
|
build_tree_access_filter(current_user),
|
||||||
Tree.is_default == True,
|
|
||||||
Tree.is_public == True,
|
|
||||||
Tree.author_id == current_user.id
|
|
||||||
),
|
|
||||||
search_vector.op('@@')(search_query)
|
search_vector.op('@@')(search_query)
|
||||||
).order_by(
|
).order_by(
|
||||||
func.ts_rank(search_vector, search_query).desc()
|
func.ts_rank(search_vector, search_query).desc()
|
||||||
).limit(limit)
|
).limit(limit)
|
||||||
|
|
||||||
result = await db.execute(query)
|
result = await db.execute(query)
|
||||||
trees = result.scalars().all()
|
trees = result.scalars().unique().all()
|
||||||
return trees
|
|
||||||
|
return [build_tree_response(tree) for tree in trees]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{tree_id}", response_model=TreeResponse)
|
@router.get("/{tree_id}", response_model=TreeResponse)
|
||||||
@@ -108,7 +228,14 @@ async def get_tree(
|
|||||||
current_user: Annotated[User, Depends(get_current_user)]
|
current_user: Annotated[User, Depends(get_current_user)]
|
||||||
):
|
):
|
||||||
"""Get a specific tree by ID."""
|
"""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()
|
tree = result.scalar_one_or_none()
|
||||||
|
|
||||||
if not tree:
|
if not tree:
|
||||||
@@ -117,15 +244,21 @@ async def get_tree(
|
|||||||
detail="Tree not found"
|
detail="Tree not found"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check access: tree must be active AND (default OR public OR author)
|
# 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
|
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:
|
if not tree.is_active or not can_access:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="You don't have access to this tree"
|
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)
|
@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)],
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
current_user: Annotated[User, Depends(require_engineer_or_admin)]
|
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
|
# Only admins can create default/system trees
|
||||||
is_default = tree_data.is_default and current_user.role == "admin"
|
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(
|
new_tree = Tree(
|
||||||
name=tree_data.name,
|
name=tree_data.name,
|
||||||
description=tree_data.description,
|
description=tree_data.description,
|
||||||
category=tree_data.category,
|
category=tree_data.category,
|
||||||
|
category_id=tree_data.category_id,
|
||||||
tree_structure=tree_data.tree_structure,
|
tree_structure=tree_data.tree_structure,
|
||||||
author_id=None if is_default else current_user.id, # Default trees have no author
|
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,
|
team_id=None if is_default else current_user.team_id,
|
||||||
@@ -149,9 +306,67 @@ async def create_tree(
|
|||||||
is_default=is_default
|
is_default=is_default
|
||||||
)
|
)
|
||||||
db.add(new_tree)
|
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.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)
|
@router.put("/{tree_id}", response_model=TreeResponse)
|
||||||
@@ -161,8 +376,20 @@ async def update_tree(
|
|||||||
db: Annotated[AsyncSession, Depends(get_db)],
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
current_user: Annotated[User, Depends(require_engineer_or_admin)]
|
current_user: Annotated[User, Depends(require_engineer_or_admin)]
|
||||||
):
|
):
|
||||||
"""Update an existing tree (engineers and admins only)."""
|
"""Update an existing tree (engineers and admins only).
|
||||||
result = await db.execute(select(Tree).where(Tree.id == tree_id))
|
|
||||||
|
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()
|
tree = result.scalar_one_or_none()
|
||||||
|
|
||||||
if not tree:
|
if not tree:
|
||||||
@@ -171,15 +398,40 @@ async def update_tree(
|
|||||||
detail="Tree not found"
|
detail="Tree not found"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check if user can edit: must be author or admin
|
# Check if user can edit: must be author, team admin for team trees, or global admin
|
||||||
if tree.author_id != current_user.id and current_user.role != "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(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="You can only edit your own trees"
|
detail="You can only edit your own trees"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Update fields
|
# Extract tags for separate handling
|
||||||
update_data = tree_data.model_dump(exclude_unset=True)
|
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():
|
for field, value in update_data.items():
|
||||||
setattr(tree, field, value)
|
setattr(tree, field, value)
|
||||||
|
|
||||||
@@ -187,9 +439,73 @@ async def update_tree(
|
|||||||
if "tree_structure" in update_data:
|
if "tree_structure" in update_data:
|
||||||
tree.version += 1
|
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.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)
|
@router.delete("/{tree_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from fastapi import APIRouter
|
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()
|
api_router = APIRouter()
|
||||||
|
|
||||||
@@ -7,3 +7,6 @@ api_router.include_router(auth.router)
|
|||||||
api_router.include_router(trees.router)
|
api_router.include_router(trees.router)
|
||||||
api_router.include_router(sessions.router)
|
api_router.include_router(sessions.router)
|
||||||
api_router.include_router(invite.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)
|
||||||
|
|||||||
@@ -4,5 +4,20 @@ from .tree import Tree
|
|||||||
from .session import Session
|
from .session import Session
|
||||||
from .attachment import Attachment
|
from .attachment import Attachment
|
||||||
from .invite_code import InviteCode
|
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",
|
||||||
|
]
|
||||||
|
|||||||
66
backend/app/models/category.py
Normal file
66
backend/app/models/category.py
Normal 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
|
||||||
93
backend/app/models/folder.py
Normal file
93
backend/app/models/folder.py
Normal 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
86
backend/app/models/tag.py
Normal 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
|
||||||
@@ -1,10 +1,17 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
from sqlalchemy import String, DateTime
|
from sqlalchemy import String, DateTime
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
from app.core.database import Base
|
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):
|
class Team(Base):
|
||||||
__tablename__ = "teams"
|
__tablename__ = "teams"
|
||||||
@@ -23,3 +30,5 @@ class Team(Base):
|
|||||||
# Relationships
|
# Relationships
|
||||||
users: Mapped[list["User"]] = relationship("User", back_populates="team")
|
users: Mapped[list["User"]] = relationship("User", back_populates="team")
|
||||||
trees: Mapped[list["Tree"]] = relationship("Tree", 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")
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
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 import String, Text, DateTime, ForeignKey, Boolean, Integer, Index
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||||
from app.core.database import Base
|
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):
|
class Tree(Base):
|
||||||
__tablename__ = "trees"
|
__tablename__ = "trees"
|
||||||
@@ -17,7 +25,16 @@ class Tree(Base):
|
|||||||
)
|
)
|
||||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
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)
|
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)
|
tree_structure: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
|
||||||
author_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
author_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||||
UUID(as_uuid=True),
|
UUID(as_uuid=True),
|
||||||
@@ -50,4 +67,22 @@ class Tree(Base):
|
|||||||
team: Mapped[Optional["Team"]] = relationship("Team", back_populates="trees")
|
team: Mapped[Optional["Team"]] = relationship("Team", back_populates="trees")
|
||||||
sessions: Mapped[list["Session"]] = relationship("Session", back_populates="tree")
|
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
|
# 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 []
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Optional
|
from typing import Optional, TYPE_CHECKING
|
||||||
from sqlalchemy import String, DateTime, ForeignKey
|
from sqlalchemy import String, DateTime, ForeignKey, Boolean
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
from app.core.database import Base
|
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):
|
class User(Base):
|
||||||
__tablename__ = "users"
|
__tablename__ = "users"
|
||||||
@@ -19,6 +25,7 @@ class User(Base):
|
|||||||
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
name: 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")
|
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(
|
team_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||||
UUID(as_uuid=True),
|
UUID(as_uuid=True),
|
||||||
ForeignKey("teams.id"),
|
ForeignKey("teams.id"),
|
||||||
@@ -39,3 +46,14 @@ class User(Base):
|
|||||||
team: Mapped[Optional["Team"]] = relationship("Team", back_populates="users")
|
team: Mapped[Optional["Team"]] = relationship("Team", back_populates="users")
|
||||||
trees: Mapped[list["Tree"]] = relationship("Tree", back_populates="author")
|
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)
|
||||||
|
|||||||
@@ -2,10 +2,23 @@ from .user import UserCreate, UserUpdate, UserResponse, UserLogin
|
|||||||
from .token import Token, TokenPayload
|
from .token import Token, TokenPayload
|
||||||
from .tree import TreeCreate, TreeUpdate, TreeResponse, TreeListResponse
|
from .tree import TreeCreate, TreeUpdate, TreeResponse, TreeListResponse
|
||||||
from .session import SessionCreate, SessionUpdate, SessionResponse, SessionExport, DecisionRecord
|
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__ = [
|
__all__ = [
|
||||||
|
# User
|
||||||
"UserCreate", "UserUpdate", "UserResponse", "UserLogin",
|
"UserCreate", "UserUpdate", "UserResponse", "UserLogin",
|
||||||
|
# Token
|
||||||
"Token", "TokenPayload",
|
"Token", "TokenPayload",
|
||||||
|
# Tree
|
||||||
"TreeCreate", "TreeUpdate", "TreeResponse", "TreeListResponse",
|
"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",
|
||||||
]
|
]
|
||||||
|
|||||||
58
backend/app/schemas/category.py
Normal file
58
backend/app/schemas/category.py
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
from uuid import UUID
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
|
def slugify(name: str) -> str:
|
||||||
|
"""Convert a name to a URL-safe slug."""
|
||||||
|
# Remove non-alphanumeric chars except spaces, convert to lowercase
|
||||||
|
slug = re.sub(r'[^a-zA-Z0-9 ]', '', name.lower())
|
||||||
|
# Replace spaces with hyphens
|
||||||
|
slug = re.sub(r' +', '-', slug.strip())
|
||||||
|
return slug
|
||||||
|
|
||||||
|
|
||||||
|
class 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
|
||||||
62
backend/app/schemas/folder.py
Normal file
62
backend/app/schemas/folder.py
Normal 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
|
||||||
56
backend/app/schemas/tag.py
Normal file
56
backend/app/schemas/tag.py
Normal 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")
|
||||||
@@ -4,9 +4,20 @@ from uuid import UUID
|
|||||||
from pydantic import BaseModel, Field
|
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):
|
class TreeBase(BaseModel):
|
||||||
name: str = Field(..., min_length=1, max_length=255)
|
name: str = Field(..., min_length=1, max_length=255)
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
|
# Legacy category field - kept for backward compatibility
|
||||||
category: Optional[str] = Field(None, max_length=100)
|
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")
|
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_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)")
|
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):
|
class TreeUpdate(BaseModel):
|
||||||
name: Optional[str] = Field(None, min_length=1, max_length=255)
|
name: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
category: Optional[str] = Field(None, max_length=100)
|
category: Optional[str] = Field(None, max_length=100)
|
||||||
|
category_id: Optional[UUID] = None
|
||||||
tree_structure: Optional[dict[str, Any]] = None
|
tree_structure: Optional[dict[str, Any]] = None
|
||||||
is_public: Optional[bool] = None
|
is_public: Optional[bool] = None
|
||||||
is_active: 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):
|
class TreeResponse(TreeBase):
|
||||||
@@ -30,6 +45,9 @@ class TreeResponse(TreeBase):
|
|||||||
tree_structure: dict[str, Any]
|
tree_structure: dict[str, Any]
|
||||||
author_id: Optional[UUID] = None
|
author_id: Optional[UUID] = None
|
||||||
team_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_active: bool
|
||||||
is_public: bool
|
is_public: bool
|
||||||
is_default: bool
|
is_default: bool
|
||||||
@@ -47,6 +65,10 @@ class TreeListResponse(BaseModel):
|
|||||||
name: str
|
name: str
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
category: 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_active: bool
|
||||||
is_public: bool
|
is_public: bool
|
||||||
is_default: bool
|
is_default: bool
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ class UserLogin(BaseModel):
|
|||||||
class UserResponse(UserBase):
|
class UserResponse(UserBase):
|
||||||
id: UUID
|
id: UUID
|
||||||
role: str
|
role: str
|
||||||
|
is_team_admin: bool = False
|
||||||
team_id: Optional[UUID] = None
|
team_id: Optional[UUID] = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
last_login: Optional[datetime] = None
|
last_login: Optional[datetime] = None
|
||||||
|
|||||||
32
frontend/src/api/categories.ts
Normal file
32
frontend/src/api/categories.ts
Normal 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
|
||||||
50
frontend/src/api/folders.ts
Normal file
50
frontend/src/api/folders.ts
Normal 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
|
||||||
@@ -3,3 +3,6 @@ export { default as authApi } from './auth'
|
|||||||
export { default as treesApi } from './trees'
|
export { default as treesApi } from './trees'
|
||||||
export { default as sessionsApi } from './sessions'
|
export { default as sessionsApi } from './sessions'
|
||||||
export { default as inviteApi } from './invite'
|
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
54
frontend/src/api/tags.ts
Normal 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
|
||||||
@@ -1,23 +1,8 @@
|
|||||||
import apiClient from './client'
|
import apiClient from './client'
|
||||||
import type { Tree, TreeListItem, TreeCreate, TreeUpdate } from '@/types'
|
import type { Tree, TreeListItem, TreeCreate, TreeUpdate, TreeFilters } 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
|
|
||||||
}
|
|
||||||
|
|
||||||
export const treesApi = {
|
export const treesApi = {
|
||||||
async list(params?: TreeListParams): Promise<TreeListItem[]> {
|
async list(params?: TreeFilters): Promise<TreeListItem[]> {
|
||||||
const response = await apiClient.get<TreeListItem[]>('/trees', { params })
|
const response = await apiClient.get<TreeListItem[]>('/trees', { params })
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
@@ -41,14 +26,15 @@ export const treesApi = {
|
|||||||
await apiClient.delete(`/trees/${id}`)
|
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')
|
const response = await apiClient.get<string[]>('/trees/categories')
|
||||||
return response.data
|
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', {
|
const response = await apiClient.get<TreeListItem[]>('/trees/search', {
|
||||||
params: { q: query, category },
|
params: { q: query, limit },
|
||||||
})
|
})
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|||||||
64
frontend/src/components/common/TagBadges.tsx
Normal file
64
frontend/src/components/common/TagBadges.tsx
Normal 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
|
||||||
230
frontend/src/components/common/TagInput.tsx
Normal file
230
frontend/src/components/common/TagInput.tsx
Normal 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
|
||||||
155
frontend/src/components/library/AddToFolderMenu.tsx
Normal file
155
frontend/src/components/library/AddToFolderMenu.tsx
Normal 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
|
||||||
291
frontend/src/components/library/FolderEditModal.tsx
Normal file
291
frontend/src/components/library/FolderEditModal.tsx
Normal 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
|
||||||
484
frontend/src/components/library/FolderSidebar.tsx
Normal file
484
frontend/src/components/library/FolderSidebar.tsx
Normal 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
|
||||||
@@ -1,27 +1,54 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { treesApi } from '@/api'
|
import { categoriesApi } from '@/api'
|
||||||
import { useTreeEditorStore } from '@/store/treeEditorStore'
|
import { useTreeEditorStore } from '@/store/treeEditorStore'
|
||||||
|
import { TagInput } from '@/components/common/TagInput'
|
||||||
|
import type { CategoryListItem } from '@/types'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Globe, Lock } from 'lucide-react'
|
||||||
|
|
||||||
export function TreeMetadataForm() {
|
export function TreeMetadataForm() {
|
||||||
const { name, description, category, setName, setDescription, setCategory, validationErrors } =
|
const {
|
||||||
useTreeEditorStore()
|
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)
|
const [customCategory, setCustomCategory] = useState(false)
|
||||||
|
|
||||||
// Load existing categories
|
// Load categories
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
treesApi.categories().then(setCategories).catch(console.error)
|
categoriesApi.list().then(setCategories).catch(console.error)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleCategoryChange = (value: string) => {
|
const handleCategoryChange = (value: string) => {
|
||||||
if (value === '__custom__') {
|
if (value === '__custom__') {
|
||||||
setCustomCategory(true)
|
setCustomCategory(true)
|
||||||
setCategory('')
|
setCategory('')
|
||||||
|
setCategoryId(null)
|
||||||
|
} else if (value === '') {
|
||||||
|
setCustomCategory(false)
|
||||||
|
setCategory('')
|
||||||
|
setCategoryId(null)
|
||||||
} else {
|
} else {
|
||||||
setCustomCategory(false)
|
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 ? 'border-destructive' : 'border-input'
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{nameError && (
|
{nameError && <p className="mt-1 text-xs text-destructive">{nameError.message}</p>}
|
||||||
<p className="mt-1 text-xs text-destructive">{nameError.message}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
@@ -83,7 +108,7 @@ export function TreeMetadataForm() {
|
|||||||
{!customCategory ? (
|
{!customCategory ? (
|
||||||
<select
|
<select
|
||||||
id="tree-category"
|
id="tree-category"
|
||||||
value={category || ''}
|
value={categoryId || ''}
|
||||||
onChange={(e) => handleCategoryChange(e.target.value)}
|
onChange={(e) => handleCategoryChange(e.target.value)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'mt-1 block w-full rounded-md border border-input px-3 py-2 text-sm',
|
'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>
|
<option value="">No category</option>
|
||||||
{categories.map((cat) => (
|
{categories.map((cat) => (
|
||||||
<option key={cat} value={cat}>
|
<option key={cat.id} value={cat.id}>
|
||||||
{cat}
|
{cat.name}
|
||||||
|
{cat.team_id ? ' (Team)' : ''}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
<option value="__custom__">+ Add custom category</option>
|
<option value="__custom__">+ Add custom category</option>
|
||||||
@@ -117,6 +143,7 @@ export function TreeMetadataForm() {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setCustomCategory(false)
|
setCustomCategory(false)
|
||||||
setCategory('')
|
setCategory('')
|
||||||
|
setCategoryId(null)
|
||||||
}}
|
}}
|
||||||
className="rounded-md border border-input px-3 py-2 text-sm hover:bg-accent"
|
className="rounded-md border border-input px-3 py-2 text-sm hover:bg-accent"
|
||||||
>
|
>
|
||||||
@@ -125,6 +152,60 @@ export function TreeMetadataForm() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -138,12 +138,14 @@ export function TreeEditorPage() {
|
|||||||
const treeData = getTreeForSave()
|
const treeData = getTreeForSave()
|
||||||
if (isEditMode) {
|
if (isEditMode) {
|
||||||
await treesApi.update(id!, treeData as TreeUpdate)
|
await treesApi.update(id!, treeData as TreeUpdate)
|
||||||
|
markSaved()
|
||||||
} else {
|
} else {
|
||||||
const newTree = await treesApi.create(treeData as TreeCreate)
|
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 to edit mode with the new ID
|
||||||
navigate(`/trees/${newTree.id}/edit`, { replace: true })
|
navigate(`/trees/${newTree.id}/edit`, { replace: true })
|
||||||
}
|
}
|
||||||
markSaved()
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to save tree:', err)
|
console.error('Failed to save tree:', err)
|
||||||
setSaveError('Failed to save tree. Please try again.')
|
setSaveError('Failed to save tree. Please try again.')
|
||||||
|
|||||||
@@ -1,30 +1,63 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
import { useNavigate, Link } from 'react-router-dom'
|
import { useNavigate, Link } from 'react-router-dom'
|
||||||
import { Plus, Pencil } from 'lucide-react'
|
import { Plus, Pencil, Globe, Lock, X } from 'lucide-react'
|
||||||
import { treesApi } from '@/api'
|
import { treesApi, categoriesApi, foldersApi } from '@/api'
|
||||||
import type { TreeListItem } from '@/types'
|
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'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
export function TreeLibraryPage() {
|
export function TreeLibraryPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [trees, setTrees] = useState<TreeListItem[]>([])
|
const [trees, setTrees] = useState<TreeListItem[]>([])
|
||||||
const [categories, setCategories] = useState<string[]>([])
|
const [categories, setCategories] = useState<CategoryListItem[]>([])
|
||||||
const [selectedCategory, setSelectedCategory] = useState<string>('')
|
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 [searchQuery, setSearchQuery] = useState('')
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
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(() => {
|
useEffect(() => {
|
||||||
loadData()
|
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 () => {
|
const loadData = async () => {
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
const [treesData, categoriesData] = await Promise.all([
|
const [treesData, categoriesData] = await Promise.all([
|
||||||
treesApi.list({ category: selectedCategory || undefined }),
|
treesApi.list({
|
||||||
treesApi.categories(),
|
category_id: selectedCategoryId || undefined,
|
||||||
|
tags: selectedTags.length > 0 ? selectedTags.join(',') : undefined,
|
||||||
|
folder_id: selectedFolderId || undefined,
|
||||||
|
}),
|
||||||
|
categoriesApi.list(),
|
||||||
])
|
])
|
||||||
setTrees(treesData)
|
setTrees(treesData)
|
||||||
setCategories(categoriesData)
|
setCategories(categoriesData)
|
||||||
@@ -44,7 +77,7 @@ export function TreeLibraryPage() {
|
|||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
const results = await treesApi.search(searchQuery, selectedCategory || undefined)
|
const results = await treesApi.search(searchQuery)
|
||||||
setTrees(results)
|
setTrees(results)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError('Search failed')
|
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) => {
|
const handleStartSession = (treeId: string) => {
|
||||||
navigate(`/trees/${treeId}/navigate`)
|
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 (
|
return (
|
||||||
<div className="container mx-auto px-4 py-8">
|
<div className="flex h-[calc(100vh-4rem)]">
|
||||||
<div className="mb-8 flex items-start justify-between">
|
{/* Folder Sidebar */}
|
||||||
<div>
|
<FolderSidebar
|
||||||
<h1 className="text-3xl font-bold text-foreground">Decision Trees</h1>
|
selectedFolderId={selectedFolderId}
|
||||||
<p className="mt-2 text-muted-foreground">
|
onFolderSelect={setSelectedFolderId}
|
||||||
Select a troubleshooting tree to start a new session
|
onCreateFolder={handleCreateFolder}
|
||||||
</p>
|
onEditFolder={handleEditFolder}
|
||||||
</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 */}
|
{/* Main Content */}
|
||||||
<div className="mb-6 flex flex-col gap-4 sm:flex-row">
|
<div className="flex-1 overflow-auto">
|
||||||
<div className="flex flex-1 gap-2">
|
<div className="container mx-auto px-4 py-8">
|
||||||
<input
|
<div className="mb-8 flex items-start justify-between">
|
||||||
type="text"
|
<div>
|
||||||
placeholder="Search trees..."
|
<h1 className="text-3xl font-bold text-foreground">Decision Trees</h1>
|
||||||
value={searchQuery}
|
<p className="mt-2 text-muted-foreground">
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
Select a troubleshooting tree to start a new session
|
||||||
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'}
|
|
||||||
</p>
|
</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>
|
</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>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
|
{/* Folder Edit Modal */}
|
||||||
|
<FolderEditModal
|
||||||
|
folder={editingFolder}
|
||||||
|
parentId={newFolderParentId}
|
||||||
|
folders={folders}
|
||||||
|
isOpen={folderModalOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setFolderModalOpen(false)
|
||||||
|
setNewFolderParentId(null)
|
||||||
|
}}
|
||||||
|
onSave={loadData}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,6 +101,9 @@ interface TreeEditorState {
|
|||||||
name: string
|
name: string
|
||||||
description: string
|
description: string
|
||||||
category: string
|
category: string
|
||||||
|
categoryId: string | null
|
||||||
|
tags: string[]
|
||||||
|
isPublic: boolean
|
||||||
treeStructure: TreeStructure | null
|
treeStructure: TreeStructure | null
|
||||||
originalTree: Tree | null // For comparison in edit mode
|
originalTree: Tree | null // For comparison in edit mode
|
||||||
|
|
||||||
@@ -127,6 +130,11 @@ interface TreeEditorState {
|
|||||||
setName: (name: string) => void
|
setName: (name: string) => void
|
||||||
setDescription: (description: string) => void
|
setDescription: (description: string) => void
|
||||||
setCategory: (category: 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
|
// Actions - Node CRUD
|
||||||
addNode: (parentId: string | null, type: NodeType, insertIndex?: number) => string
|
addNode: (parentId: string | null, type: NodeType, insertIndex?: number) => string
|
||||||
@@ -169,6 +177,9 @@ export const useTreeEditorStore = create<TreeEditorState>()(
|
|||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
category: '',
|
category: '',
|
||||||
|
categoryId: null,
|
||||||
|
tags: [],
|
||||||
|
isPublic: false,
|
||||||
treeStructure: null,
|
treeStructure: null,
|
||||||
originalTree: null,
|
originalTree: null,
|
||||||
selectedNodeId: null,
|
selectedNodeId: null,
|
||||||
@@ -188,6 +199,9 @@ export const useTreeEditorStore = create<TreeEditorState>()(
|
|||||||
state.name = ''
|
state.name = ''
|
||||||
state.description = ''
|
state.description = ''
|
||||||
state.category = ''
|
state.category = ''
|
||||||
|
state.categoryId = null
|
||||||
|
state.tags = []
|
||||||
|
state.isPublic = false
|
||||||
state.treeStructure = {
|
state.treeStructure = {
|
||||||
id: 'root',
|
id: 'root',
|
||||||
type: 'decision',
|
type: 'decision',
|
||||||
@@ -213,6 +227,9 @@ export const useTreeEditorStore = create<TreeEditorState>()(
|
|||||||
state.name = tree.name
|
state.name = tree.name
|
||||||
state.description = tree.description || ''
|
state.description = tree.description || ''
|
||||||
state.category = tree.category || ''
|
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.treeStructure = tree.tree_structure
|
||||||
state.originalTree = tree
|
state.originalTree = tree
|
||||||
state.selectedNodeId = tree.tree_structure?.id || null
|
state.selectedNodeId = tree.tree_structure?.id || null
|
||||||
@@ -236,6 +253,9 @@ export const useTreeEditorStore = create<TreeEditorState>()(
|
|||||||
state.name = draft.name || ''
|
state.name = draft.name || ''
|
||||||
state.description = draft.description || ''
|
state.description = draft.description || ''
|
||||||
state.category = draft.category || ''
|
state.category = draft.category || ''
|
||||||
|
state.categoryId = draft.categoryId || null
|
||||||
|
state.tags = draft.tags || []
|
||||||
|
state.isPublic = draft.isPublic || false
|
||||||
state.treeStructure = draft.treeStructure || null
|
state.treeStructure = draft.treeStructure || null
|
||||||
state.isDirty = true
|
state.isDirty = true
|
||||||
state.draftSavedAt = draft.savedAt ? new Date(draft.savedAt) : null
|
state.draftSavedAt = draft.savedAt ? new Date(draft.savedAt) : null
|
||||||
@@ -261,6 +281,9 @@ export const useTreeEditorStore = create<TreeEditorState>()(
|
|||||||
state.name = ''
|
state.name = ''
|
||||||
state.description = ''
|
state.description = ''
|
||||||
state.category = ''
|
state.category = ''
|
||||||
|
state.categoryId = null
|
||||||
|
state.tags = []
|
||||||
|
state.isPublic = false
|
||||||
state.treeStructure = null
|
state.treeStructure = null
|
||||||
state.originalTree = null
|
state.originalTree = null
|
||||||
state.selectedNodeId = null
|
state.selectedNodeId = null
|
||||||
@@ -299,6 +322,48 @@ export const useTreeEditorStore = create<TreeEditorState>()(
|
|||||||
get().autoSaveDraft()
|
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
|
// Node CRUD
|
||||||
addNode: (parentId: string | null, type: NodeType, insertIndex?: number) => {
|
addNode: (parentId: string | null, type: NodeType, insertIndex?: number) => {
|
||||||
const newId = generateId()
|
const newId = generateId()
|
||||||
@@ -605,6 +670,9 @@ export const useTreeEditorStore = create<TreeEditorState>()(
|
|||||||
name: state.name,
|
name: state.name,
|
||||||
description: state.description,
|
description: state.description,
|
||||||
category: state.category,
|
category: state.category,
|
||||||
|
categoryId: state.categoryId,
|
||||||
|
tags: state.tags,
|
||||||
|
isPublic: state.isPublic,
|
||||||
treeStructure: state.treeStructure,
|
treeStructure: state.treeStructure,
|
||||||
savedAt: new Date().toISOString()
|
savedAt: new Date().toISOString()
|
||||||
}
|
}
|
||||||
@@ -628,6 +696,9 @@ export const useTreeEditorStore = create<TreeEditorState>()(
|
|||||||
name: state.name,
|
name: state.name,
|
||||||
description: state.description || undefined,
|
description: state.description || undefined,
|
||||||
category: state.category || 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!
|
tree_structure: state.treeStructure!
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -694,6 +765,9 @@ export const useTreeEditorStore = create<TreeEditorState>()(
|
|||||||
name: state.name,
|
name: state.name,
|
||||||
description: state.description,
|
description: state.description,
|
||||||
category: state.category,
|
category: state.category,
|
||||||
|
categoryId: state.categoryId,
|
||||||
|
tags: state.tags,
|
||||||
|
isPublic: state.isPublic,
|
||||||
treeStructure: state.treeStructure
|
treeStructure: state.treeStructure
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
45
frontend/src/types/category.ts
Normal file
45
frontend/src/types/category.ts
Normal 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
|
||||||
|
}
|
||||||
52
frontend/src/types/folder.ts
Normal file
52
frontend/src/types/folder.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -3,6 +3,9 @@ export * from './auth'
|
|||||||
export * from './tree'
|
export * from './tree'
|
||||||
export * from './session'
|
export * from './session'
|
||||||
export * from './invite'
|
export * from './invite'
|
||||||
|
export * from './tag'
|
||||||
|
export * from './category'
|
||||||
|
export * from './folder'
|
||||||
|
|
||||||
// API response wrapper types
|
// API response wrapper types
|
||||||
export interface PaginatedResponse<T> {
|
export interface PaginatedResponse<T> {
|
||||||
|
|||||||
27
frontend/src/types/tag.ts
Normal file
27
frontend/src/types/tag.ts
Normal 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[]
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { CategoryInfo } from './category'
|
||||||
|
|
||||||
// Tree node types
|
// Tree node types
|
||||||
export type NodeType = 'decision' | 'action' | 'solution'
|
export type NodeType = 'decision' | 'action' | 'solution'
|
||||||
|
|
||||||
@@ -60,10 +62,15 @@ export interface Tree {
|
|||||||
name: string
|
name: string
|
||||||
description: string | null
|
description: string | null
|
||||||
category: string | null
|
category: string | null
|
||||||
|
category_id: string | null
|
||||||
|
category_info: CategoryInfo | null
|
||||||
|
tags: string[]
|
||||||
tree_structure: TreeStructure
|
tree_structure: TreeStructure
|
||||||
author_id: string | null
|
author_id: string | null
|
||||||
team_id: string | null
|
team_id: string | null
|
||||||
is_active: boolean
|
is_active: boolean
|
||||||
|
is_public: boolean
|
||||||
|
is_default: boolean
|
||||||
version: number
|
version: number
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
@@ -75,7 +82,13 @@ export interface TreeListItem {
|
|||||||
name: string
|
name: string
|
||||||
description: string | null
|
description: string | null
|
||||||
category: string | null
|
category: string | null
|
||||||
|
category_id: string | null
|
||||||
|
category_info: CategoryInfo | null
|
||||||
|
tags: string[]
|
||||||
|
author_id: string | null
|
||||||
is_active: boolean
|
is_active: boolean
|
||||||
|
is_public: boolean
|
||||||
|
is_default: boolean
|
||||||
version: number
|
version: number
|
||||||
usage_count: number
|
usage_count: number
|
||||||
created_at: string
|
created_at: string
|
||||||
@@ -86,13 +99,33 @@ export interface TreeCreate {
|
|||||||
name: string
|
name: string
|
||||||
description?: string
|
description?: string
|
||||||
category?: string
|
category?: string
|
||||||
|
category_id?: string | null
|
||||||
|
tags?: string[]
|
||||||
tree_structure: TreeStructure
|
tree_structure: TreeStructure
|
||||||
|
is_public?: boolean
|
||||||
|
is_default?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TreeUpdate {
|
export interface TreeUpdate {
|
||||||
name?: string
|
name?: string
|
||||||
description?: string
|
description?: string
|
||||||
category?: string
|
category?: string
|
||||||
|
category_id?: string | null
|
||||||
|
tags?: string[]
|
||||||
tree_structure?: TreeStructure
|
tree_structure?: TreeStructure
|
||||||
is_active?: boolean
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export interface User {
|
|||||||
email: string
|
email: string
|
||||||
name: string
|
name: string
|
||||||
role: UserRole
|
role: UserRole
|
||||||
|
is_team_admin: boolean
|
||||||
team_id: string | null
|
team_id: string | null
|
||||||
created_at: string
|
created_at: string
|
||||||
last_login: string | null
|
last_login: string | null
|
||||||
|
|||||||
Reference in New Issue
Block a user