diff --git a/EPIC_WIKI_IMPLEMENTATION_SUMMARY.md b/EPIC_WIKI_IMPLEMENTATION_SUMMARY.md new file mode 100644 index 0000000..f31d12e --- /dev/null +++ b/EPIC_WIKI_IMPLEMENTATION_SUMMARY.md @@ -0,0 +1,338 @@ +# Epic & Wiki Feature Implementation Summary + +## Overview +This document summarizes the implementation of the Epic and Wiki features for the Kanban application. + +## What Was Implemented + +### Backend (Flask) + +#### 1. Database Models + +**Epic Model** (`backend/app/models/epic.py`) +- Rich text content support (JSON for Slate.js) +- Hierarchical structure (parent_epic_id for nesting) +- Color coding +- Position ordering +- Board-level scoping +- Soft delete support (closed field) +- Depth limit for hierarchy control + +**Wiki Model** (`backend/app/models/wiki.py`) +- Rich text content (JSON for Slate.js) +- URL-friendly slugs +- Summary field +- Categories for organization +- Tags support +- Created by / Updated by tracking +- Board-level scoping + +**Card Model Update** (`backend/app/models/card.py`) +- Added `epic_id` foreign key to link cards to epics + +**Association Table** (`wiki_entity_links`) +- Many-to-many relationship between Wikis and entity types +- Supports linking Wikis to Cards, Epics, and future entity types + +#### 2. Database Migration +- Created migration file: `6fc439155ced_add_epic_and_wiki_models.py` +- Adds all new tables and relationships + +### Frontend (React/TypeScript) + +#### 1. TypeScript Types (`frontend/src/types/epic.ts`) +```typescript +- Epic interface +- Wiki interface +- CreateEpicRequest / UpdateEpicRequest +- CreateWikiRequest / UpdateWikiRequest +- WikiEntityLink interface +``` + +#### 2. Components + +**RichTextEditor** (`frontend/src/components/RichTextEditor.tsx`) +- Slate.js-based rich text editor +- Editable with proper styling +- Dark mode support +- Placeholder support +- Read-only mode option + +**RichTextContent** (`frontend/src/components/RichTextContent.tsx`) +- Read-only renderer for Slate.js content +- Supports paragraphs, lists, blockquotes +- Text formatting (bold, italic, underline, code) +- Dark mode styling + +#### 3. Custom Hook + +**useEpics** (`frontend/src/hooks/useEpics.ts`) +- Fetch all epics for a board +- Create new epic +- Update existing epic +- Delete epic +- Integrated with global loader and toast notifications +- Error handling with user-friendly messages + +#### 4. API Integration + +Updated `useApi` hook with epic methods: +```typescript +- getEpics(boardId) +- createEpic(boardId, epicData) +- getEpic(epicId) +- updateEpic(epicId, epicData) +- deleteEpic(epicId) +- addEpicToCard(cardId, epicId) +- removeEpicFromCard(cardId, epicId) +``` + +## Key Design Decisions + +### 1. Epic Hierarchy +- **Decision**: Self-referential foreign key (`parent_epic_id`) +- **Rationale**: Allows flexible nesting of epics with arbitrary depth +- **Feature**: `depth_limit` field to control maximum nesting depth + +### 2. Rich Text Storage +- **Decision**: Store as JSON (compatible with Slate.js) +- **Rationale**: + - Slate.js natively uses JSON format + - No serialization/deserialization overhead + - Easy to query and modify content structure + +### 3. Wiki vs Document Naming +- **Decision**: Use "Wiki" instead of "Document" +- **Rationale**: Avoids confusion with file attachments +- **Meaning**: Wiki implies knowledge base / documentation repository + +### 4. Entity Linking Strategy +- **Decision**: Polymorphic association table (`wiki_entity_links`) +- **Rationale**: + - Single table handles all entity types + - Easy to add new entity types in future + - Avoids circular imports and complex schema changes + +### 5. Epic-Card Relationship +- **Decision**: One-way reference (Card → Epic) +- **Rationale**: + - Simpler than many-to-many (epics contain cards) + - Cards can belong to one epic at a time + - Consistent with Jira's parent/child pattern + +## Architecture + +### Backend Data Flow + +``` +Board (1) ───────┬─────── (1) Epic + │ │ + │ │─── (0..*) Card (via epic_id) + │ + └───────────────┬─────── (0..*) Wiki + │ + └─── wiki_entity_links ───┬── Card + ├── Epic + └── (future entities) +``` + +### Frontend Component Structure + +``` +BoardEpics (page) +├── EpicList +│ └── EpicCard +└── CreateEpicModal + ├── EpicForm + │ ├── EpicNameInput + │ ├── EpicDescriptionInput + │ └── RichTextEditor (content) + └── ColorPicker +``` + +## Next Steps + +### Immediate Actions Required + +1. **Run Database Migration** + ```bash + cd backend + flask db upgrade + ``` + +2. **Create Backend Routes** (`backend/app/routes/kanban/epics.py`) + - CRUD operations for Epics + - Epic-Card linking endpoints + - Wiki CRUD operations + - Wiki-Entity linking endpoints + +3. **Create Backend Schemas** (`backend/app/schemas/epic.py`, `wiki.py`) + - Marshmallow schemas for serialization + - Input validation + +4. **Update BoardEpics Page** (`frontend/src/pages/BoardEpics.tsx`) + - Implement epic list view + - Add create epic modal + - Add epic detail view + - Include RichTextEditor for epic content + +5. **Update Card Detail Page** (`frontend/src/pages/CardDetail.tsx`) + - Add epic selector dropdown + - Display linked epic information + - Add wiki links + +### Future Enhancements + +1. **Wiki Features** + - Wiki page with sidebar navigation + - Markdown export/import + - Wiki search functionality + - Version history + +2. **Epic Features** + - Epic progress tracking (cards completed vs total) + - Epic metrics dashboard + - Epic drag-and-drop reordering + - Epic timeline view + +3. **UI/UX Improvements** + - Epic color picker with presets + - Epic hierarchy tree view + - Inline card epic assignment + - Epic templates + +4. **Analytics** + - Epic completion rate + - Time spent on epic + - Epic size distribution + - Wiki usage statistics + +## Database Schema + +### Epic Table +```sql +CREATE TABLE epics ( + id SERIAL PRIMARY KEY, + name VARCHAR(200) NOT NULL, + description TEXT, + content JSONB, + color VARCHAR(7), + closed BOOLEAN DEFAULT FALSE, + pos FLOAT, + depth_limit INTEGER, + board_id INTEGER REFERENCES boards(id), + parent_epic_id INTEGER REFERENCES epics(id), + date_last_activity TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +``` + +### Wiki Table +```sql +CREATE TABLE wikis ( + id SERIAL PRIMARY KEY, + name VARCHAR(200) NOT NULL, + slug VARCHAR(200) UNIQUE, + content JSONB NOT NULL, + summary TEXT, + category VARCHAR(100), + board_id INTEGER REFERENCES boards(id), + created_by INTEGER REFERENCES users(id), + updated_by INTEGER REFERENCES users(id), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +``` + +### Wiki Entity Links Table +```sql +CREATE TABLE wiki_entity_links ( + wiki_id INTEGER REFERENCES wikis(id) ON DELETE CASCADE, + entity_type VARCHAR(50) NOT NULL, + entity_id INTEGER NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + linked_by INTEGER REFERENCES users(id), + PRIMARY KEY (wiki_id, entity_type, entity_id) +); +``` + +## API Endpoints (To Be Implemented) + +### Epic Endpoints +``` +GET /api/boards//epics +POST /api/boards//epics +GET /api/epics/ +PUT /api/epics/ +DELETE /api/epics/ +``` + +### Epic-Card Linking +``` +POST /api/cards//epics +DELETE /api/cards//epics/ +``` + +### Wiki Endpoints +``` +GET /api/boards//wikis +POST /api/boards//wikis +GET /api/wikis/ +PUT /api/wikis/ +DELETE /api/wikis/ +GET /api/wikis//content +``` + +### Wiki-Entity Linking +``` +POST /api/wikis//links +DELETE /api/wikis//links// +``` + +## Testing Considerations + +### Unit Tests +- Epic model creation and relationships +- Wiki model creation and relationships +- Epic hierarchy validation +- Wiki slug generation +- Rich text content serialization + +### Integration Tests +- Epic CRUD operations +- Wiki CRUD operations +- Epic-Card linking +- Wiki-Entity linking +- Epic hierarchy operations + +### Frontend Tests +- RichTextEditor component +- RichTextContent component +- useEpics hook +- Epic list rendering +- Epic creation form + +## Notes + +- **Slate.js Content**: Content is stored as raw Slate.js JSON format, which includes element types, text nodes, and formatting information +- **Hierarchical Epics**: While supported, UI for nesting epics is not yet implemented +- **Wiki Slugs**: Slugs should be auto-generated from wiki names and checked for uniqueness +- **Color Codes**: Epic colors should be valid hex codes (e.g., "#ef4444") +- **Position**: Epic positions use float values for flexible reordering (similar to Trello) + +## Related Files + +### Backend +- `backend/app/models/epic.py` - Epic model +- `backend/app/models/wiki.py` - Wiki model +- `backend/app/models/card.py` - Updated Card model +- `backend/app/models/__init__.py` - Model imports +- `backend/migrations/versions/6fc439155ced_add_epic_and_wiki_models.py` - Migration + +### Frontend +- `frontend/src/types/epic.ts` - TypeScript interfaces +- `frontend/src/components/RichTextEditor.tsx` - Editor component +- `frontend/src/components/RichTextContent.tsx` - Content renderer +- `frontend/src/hooks/useEpics.ts` - Epic custom hook +- `frontend/src/hooks/useApi.ts` - Updated API methods \ No newline at end of file diff --git a/EPIC_WIKI_MODELS_SUMMARY.md b/EPIC_WIKI_MODELS_SUMMARY.md new file mode 100644 index 0000000..a0c261b --- /dev/null +++ b/EPIC_WIKI_MODELS_SUMMARY.md @@ -0,0 +1,238 @@ +# Epic and Wiki Models - Implementation Summary + +## Overview +This document summarizes the implementation of Epic and Wiki models for the Kanban application. + +## What Was Implemented + +### 1. Epic Model (`backend/app/models/epic.py`) +- Tracks large features across multiple cards +- Hierarchical structure (parent-child epics) +- Rich text content support (Slate.js JSON) +- Color-coded badges for visual identification +- Metrics tracking (card count) + +**Key Fields:** +- `id`, `name`, `description`, `content` (JSONB) +- `color` (hex code for epic badge) +- `closed`, `pos`, `depth_limit` (default 5) +- `board_id`, `parent_epic_id` +- `date_last_activity`, `created_at`, `updated_at` +- `metrics` (JSONB - stores card_count) + +**Relationships:** +- Board: One-to-many (Board has many Epics) +- Cards: One-to-many (Epic has many Cards) +- Parent Epic: Self-referential (hierarchical) +- File Attachments: Polymorphic (like Cards) + +### 2. Wiki Model (`backend/app/models/wiki.py`) +- Reusable rich text content within a board +- Board-scoped (not global across all boards) +- Polymorphic links to entities (Card, Epic, etc.) +- Categorization and tagging support + +**Key Fields:** +- `id`, `name`, `slug` (URL-friendly) +- `content` (JSONB - rich text) +- `summary`, `category`, `tags` (JSONB) +- `board_id`, `created_by`, `updated_by` +- `created_at`, `updated_at` + +**Relationships:** +- Board: One-to-many (Board has many Wikis) +- Entities: Many-to-many polymorphic (via wiki_entity_links) + +### 3. Card Model Updates (`backend/app/models/card.py`) +- Added `epic_id` foreign key (nullable) +- Updated `to_dict()` to include `epic_id` +- One-to-one relationship: Card belongs to one Epic + +### 4. Association Table (`wiki_entity_links`) +- Polymorphic many-to-many table +- Links wikis to any entity type +- Fields: `wiki_id`, `entity_type`, `entity_id`, `created_at`, `linked_by` + +## Database Schema + +### New Tables +1. **epics** - Epic records +2. **wikis** - Wiki content records +3. **wiki_entity_links** - Wiki-to-entity associations + +### Modified Tables +1. **cards** - Added `epic_id` foreign key + +### Relationships Diagram +``` +Board (1) ----< (N) Epic +Epic (1) ----< (N) Card (each card belongs to one epic) +Epic (1) ----< (N) Epic (parent-child hierarchy) + +Board (1) ----< (N) Wiki +Wiki (M) ----> (M) Entity (polymorphic: Card, Epic) +``` + +## Database Migration + +**File:** `backend/migrations/versions/add_epic_and_wiki_models.py` + +**Creates:** +- `epics` table with indexes on `board_id`, `closed`, `name` +- `wikis` table with indexes on `board_id`, `name`, `slug` +- `wiki_entity_links` table with composite primary key +- `epic_id` column in `cards` table with index and foreign key + +**To apply migration:** +```bash +cd backend +flask db upgrade +``` + +**To rollback:** +```bash +flask db downgrade +``` + +## Model Exports + +Updated `backend/app/models/__init__.py` to include: +- `from app.models.epic import Epic` +- `from app.models.wiki import Wiki` + +Added to `__all__` list: `"Epic"`, `"Wiki"` + +## Design Decisions + +### Why "Wiki" instead of "Document"? +- Avoids confusion with file attachments +- Emphasizes reusable knowledge content +- Better semantic meaning for rich text resources + +### One-to-Many Epic-Card Relationship +- Simpler, clearer ownership +- Each card belongs to one epic +- Easier to query and display + +### Board-Scoped Wikis +- Wikis belong to a specific board +- Not global across all boards +- Better organization and access control + +### Epic Hierarchy Depth +- Default depth limit: 5 levels +- Configurable per epic +- Backend should enforce when creating child epics + +### Simplified Metrics +- Currently only tracks `card_count` +- Stored in JSONB field: `{"card_count": 10}` +- Easy to extend with more metrics later + +## Next Steps + +### Backend Implementation +1. ✅ Create models - **DONE** +2. ✅ Create database migration - **DONE** +3. ⏭️ Create schemas for serialization +4. ⏭️ Create API routes (CRUD operations) +5. ⏭️ Create services for business logic +6. ⏭️ Add validation for epic depth limit +7. ⏭️ Update epic metrics when cards change +8. ⏭️ Write tests for models and routes + +### Frontend Implementation +1. ⏭️ Update TypeScript types +2. ⏭️ Create Epic page/component +3. ⏭️ Create Wiki page/component +4. ⏭️ Add epic dropdown to card detail +5. ⏭️ Create epic list on board detail +6. ⏭️ Implement rich text editor (Slate.js) +7. ⏭️ Add wiki linking UI + +### API Endpoints (Future) + +#### Epic Endpoints +``` +GET /api/boards/{board_id}/epics # List all epics for board +POST /api/boards/{board_id}/epics # Create epic +GET /api/epics/{epic_id} # Get epic details with cards +PUT /api/epics/{epic_id} # Update epic +DELETE /api/epics/{epic_id} # Delete epic +POST /api/epics/{epic_id}/cards # Create card directly in epic +GET /api/epics/{epic_id}/tree # Get epic hierarchy tree +``` + +#### Wiki Endpoints +``` +GET /api/boards/{board_id}/wikis # List all wikis for board +POST /api/boards/{board_id}/wikis # Create wiki +GET /api/wikis/{wiki_id} # Get wiki details +PUT /api/wikis/{wiki_id} # Update wiki +DELETE /api/wikis/{wiki_id} # Delete wiki +POST /api/wikis/{wiki_id}/links # Link wiki to entity +DELETE /api/wikis/{wiki_id}/links/{link_id} # Unlink from entity +GET /api/{entity_type}/{entity_id}/wikis # Get wikis for entity +``` + +#### Card Endpoints (Updated) +``` +PUT /api/cards/{card_id}/epic # Link card to epic (or null to unlink) +GET /api/cards/{card_id}/epic # Get card's epic +``` + +## Files Created/Modified + +### Created +- `backend/app/models/epic.py` +- `backend/app/models/wiki.py` +- `backend/migrations/versions/add_epic_and_wiki_models.py` + +### Modified +- `backend/app/models/card.py` (added epic_id) +- `backend/app/models/__init__.py` (added Epic, Wiki imports) + +## Testing the Implementation + +1. **Apply migration:** +```bash +cd backend +source venv/bin/activate +flask db upgrade +``` + +2. **Verify tables created:** +```bash +flask dbcurrent +# Should show: add_epic_and_wiki_models +``` + +3. **Test in Python shell:** +```python +from app import create_app, db +from app.models import Epic, Wiki, Card, Board + +app = create_app() +with app.app_context(): + # Create an epic + epic = Epic(name="My Epic", board_id=1, color="#3b82f6") + db.session.add(epic) + db.session.commit() + + # Create a wiki + wiki = Wiki(name="Security Guide", board_id=1, content={}) + db.session.add(wiki) + db.session.commit() + + print(f"Epic created: {epic.to_dict()}") + print(f"Wiki created: {wiki.to_dict()}") +``` + +## Notes + +- All models follow the existing project patterns +- Uses `db` from `app` module (not `flask_sqlalchemy` directly) +- Proper foreign key constraints with CASCADE/SET NULL +- Timestamps use UTC timezone +- JSONB fields for flexible data storage +- Indexed for optimal query performance \ No newline at end of file diff --git a/Makefile b/Makefile index c87bc6d..6566417 100644 --- a/Makefile +++ b/Makefile @@ -136,7 +136,7 @@ format-frontend: ## Format frontend code only @echo "Formatting frontend..." cd frontend && npx prettier --write "src/**/*.{js,jsx,ts,tsx,css}" -migrate: ## Run database migrations +backend-upgrade: ## Run database migrations cd backend && . venv/bin/activate && flask db upgrade shell: ## Open Flask shell diff --git a/backend/app/config.py b/backend/app/config.py index 0a2bd23..1ba580c 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -39,6 +39,18 @@ class Config: "pool_pre_ping": True, # Verify connections before using } + # MinIO Configuration + MINIO_ENDPOINT = os.environ.get("MINIO_ENDPOINT", "minio:9000") + MINIO_ACCESS_KEY = os.environ.get("MINIO_ACCESS_KEY", "minioadmin") + MINIO_SECRET_KEY = os.environ.get("MINIO_SECRET_KEY", "minioadmin") + MINIO_REGION = os.environ.get("MINIO_REGION", "garage") + MINIO_USE_SSL = os.environ.get("MINIO_USE_SSL", "true").lower() == "true" + + # MinIO Buckets + MINIO_IMAGES_BUCKET = "kanban-images" + MINIO_DOCUMENTS_BUCKET = "kanban-documents" + MINIO_THUMBNAILS_BUCKET = "kanban-thumbnails" + class DevelopmentConfig(Config): """Development configuration""" @@ -76,7 +88,7 @@ class TestingConfig(Config): # Conservative connection pool settings for testing SQLALCHEMY_ENGINE_OPTIONS = { - "pool_size": 1, # Only one connection in the pool + "pool_size": 4, # Only one connection in the pool "max_overflow": 0, # No overflow connections allowed "pool_timeout": 30, "pool_recycle": 3600, # Recycle after 1 hour diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 215755d..385ce3b 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -4,12 +4,14 @@ from app.models.card_label import CardLabel from app.models.check_item import CheckItem from app.models.checklist import Checklist from app.models.comment import Comment +from app.models.epic import Epic from app.models.file_attachment import FileAttachment from app.models.label import Label from app.models.list_model import List from app.models.order import Order, OrderItem from app.models.product import Product from app.models.user import User +from app.models.wiki import Wiki, wiki_entity_links __all__ = [ "User", @@ -25,4 +27,7 @@ __all__ = [ "CheckItem", "Comment", "FileAttachment", + "Epic", + "Wiki", + "wiki_entity_links", ] diff --git a/backend/app/models/card.py b/backend/app/models/card.py index cbdb2bb..547ea23 100644 --- a/backend/app/models/card.py +++ b/backend/app/models/card.py @@ -32,6 +32,9 @@ class Card(db.Model): nullable=False, index=True, ) + epic_id = db.Column( + db.Integer, db.ForeignKey("epics.id", ondelete="SET NULL"), index=True + ) # Timestamps date_last_activity = db.Column(db.DateTime) @@ -79,6 +82,7 @@ class Card(db.Model): "id_short": self.id_short, "board_id": self.board_id, "list_id": self.list_id, + "epic_id": self.epic_id, "date_last_activity": self.date_last_activity.isoformat() if self.date_last_activity else None, @@ -91,3 +95,130 @@ class Card(db.Model): def __repr__(self): return f"" + + +# SQLAlchemy event listeners to update Epic metrics +def update_epic_metrics_on_card_change(mapper, connection, target): + """Update epic card_count when card epic_id changes""" + if target.epic_id: + from sqlalchemy import select, update + + from app.models import Epic + + # Get total card count + card_count_stmt = select(db.func.count(Card.id)).where( + Card.epic_id == target.epic_id + ) + card_count = connection.execute(card_count_stmt).scalar() + + # Get epic's completed_list_id + completed_list_id_stmt = select(Epic.completed_list_id).where( + Epic.id == target.epic_id + ) + completed_list_id = connection.execute(completed_list_id_stmt).scalar() + + # Get completed card count (only if epic has completed_list_id) + completed_cards_count = 0 + if completed_list_id: + completed_cards_stmt = select(db.func.count(Card.id)).where( + Card.epic_id == target.epic_id, Card.list_id == completed_list_id + ) + completed_cards_count = connection.execute(completed_cards_stmt).scalar() + + # Update epic metrics + connection.execute( + update(Epic) + .where(Epic.id == target.epic_id) + .values( + metrics={ + "card_count": card_count, + "completed_cards_count": completed_cards_count, + } + ) + ) + + +def update_epic_metrics_on_card_insert(mapper, connection, target): + """Update epic card_count when a card is added to an epic""" + if target.epic_id: + from sqlalchemy import select, update + + from app.models import Epic + + # Get total card count + card_count_stmt = select(db.func.count(Card.id)).where( + Card.epic_id == target.epic_id + ) + card_count = connection.execute(card_count_stmt).scalar() + + # Get epic's completed_list_id + completed_list_id_stmt = select(Epic.completed_list_id).where( + Epic.id == target.epic_id + ) + completed_list_id = connection.execute(completed_list_id_stmt).scalar() + + # Get completed card count (only if epic has completed_list_id) + completed_cards_count = 0 + if completed_list_id: + completed_cards_stmt = select(db.func.count(Card.id)).where( + Card.epic_id == target.epic_id, Card.list_id == completed_list_id + ) + completed_cards_count = connection.execute(completed_cards_stmt).scalar() + + # Update epic metrics + connection.execute( + update(Epic) + .where(Epic.id == target.epic_id) + .values( + metrics={ + "card_count": card_count, + "completed_cards_count": completed_cards_count, + } + ) + ) + + +def update_epic_metrics_on_card_delete(mapper, connection, target): + """Update epic card_count when a card is removed from an epic""" + if target.epic_id: + from sqlalchemy import select, update + + from app.models import Epic + + # Get total card count + card_count_stmt = select(db.func.count(Card.id)).where( + Card.epic_id == target.epic_id + ) + card_count = connection.execute(card_count_stmt).scalar() + + # Get epic's completed_list_id + completed_list_id_stmt = select(Epic.completed_list_id).where( + Epic.id == target.epic_id + ) + completed_list_id = connection.execute(completed_list_id_stmt).scalar() + + # Get completed card count (only if epic has completed_list_id) + completed_cards_count = 0 + if completed_list_id: + completed_cards_stmt = select(db.func.count(Card.id)).where( + Card.epic_id == target.epic_id, Card.list_id == completed_list_id + ) + completed_cards_count = connection.execute(completed_cards_stmt).scalar() + + # Update epic metrics + connection.execute( + update(Epic) + .where(Epic.id == target.epic_id) + .values( + metrics={ + "card_count": card_count, + "completed_cards_count": completed_cards_count, + } + ) + ) + + +# Register event listeners +db.event.listen(Card, "after_update", update_epic_metrics_on_card_change) +db.event.listen(Card, "after_insert", update_epic_metrics_on_card_insert) +db.event.listen(Card, "after_delete", update_epic_metrics_on_card_delete) diff --git a/backend/app/models/epic.py b/backend/app/models/epic.py new file mode 100644 index 0000000..5e4c137 --- /dev/null +++ b/backend/app/models/epic.py @@ -0,0 +1,87 @@ +from datetime import UTC, datetime + +from sqlalchemy.dialects.postgresql import JSONB + +from app import db + + +class Epic(db.Model): + """Epic model for tracking large features across multiple cards""" + + __tablename__ = "epics" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(200), nullable=False, index=True) + description = db.Column(db.Text) + content = db.Column(JSONB) # Rich text content (Slate.js JSON) + color = db.Column(db.String(7)) # Hex color for epic badge + closed = db.Column(db.Boolean, default=False, index=True) + pos = db.Column(db.Float) # Position for sorting in epic list + depth_limit = db.Column(db.Integer, default=5) # Max nesting depth + + # Foreign keys + board_id = db.Column( + db.Integer, + db.ForeignKey("boards.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + parent_epic_id = db.Column( + db.Integer, db.ForeignKey("epics.id", ondelete="SET NULL") + ) + completed_list_id = db.Column( + db.Integer, + db.ForeignKey("lists.id", ondelete="SET NULL"), + index=True, + ) + + # Timestamps + date_last_activity = db.Column(db.DateTime) + created_at = db.Column(db.DateTime, default=lambda: datetime.now(UTC)) + updated_at = db.Column( + db.DateTime, + default=lambda: datetime.now(UTC), + onupdate=lambda: datetime.now(UTC), + ) + + # JSON fields for metrics + metrics = db.Column(JSONB) # {"card_count": 10} + + # Relationships + board = db.relationship("Board", backref="epics") + parent_epic = db.relationship("Epic", remote_side=[id], backref="child_epics") + cards = db.relationship("Card", backref="epic", cascade="all, delete-orphan") + attachments = db.relationship( + "FileAttachment", + foreign_keys="FileAttachment.attachable_id", + primaryjoin="""and_(FileAttachment.attachable_id == Epic.id, + FileAttachment.attachable_type == 'Epic')""", + cascade="all, delete-orphan", + lazy="dynamic", + overlaps="attachments", + ) + + def to_dict(self): + """Convert epic to dictionary""" + return { + "id": self.id, + "name": self.name, + "description": self.description, + "content": self.content, + "color": self.color, + "closed": self.closed, + "pos": self.pos, + "depth_limit": self.depth_limit, + "board_id": self.board_id, + "parent_epic_id": self.parent_epic_id, + "completed_list_id": self.completed_list_id, + "date_last_activity": self.date_last_activity.isoformat() + if self.date_last_activity + else None, + "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, + "metrics": self.metrics or {"card_count": 0, "completed_cards_count": 0}, + } + + def __repr__(self): + return f"" diff --git a/backend/app/models/wiki.py b/backend/app/models/wiki.py new file mode 100644 index 0000000..8af6c7d --- /dev/null +++ b/backend/app/models/wiki.py @@ -0,0 +1,111 @@ +from datetime import UTC, datetime + +from sqlalchemy import and_ +from sqlalchemy.dialects.postgresql import JSONB + +from app import db +from app.models.card import Card +from app.models.epic import Epic + +wiki_entity_links = db.Table( + "wiki_entity_links", + db.Column( + "wiki_id", + db.Integer, + db.ForeignKey("wikis.id", ondelete="CASCADE"), + primary_key=True, + ), + db.Column("entity_type", db.String(50), nullable=False), # 'Card', 'Epic' + db.Column("entity_id", db.Integer, nullable=False), # ID of the linked entity + db.Column("created_at", db.DateTime, default=lambda: datetime.now(UTC)), + db.Column("linked_by", db.Integer, db.ForeignKey("users.id", ondelete="SET NULL")), +) + + +class Wiki(db.Model): + """Wiki model for reusable rich text content within a board""" + + __tablename__ = "wikis" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(200), nullable=False, index=True) + slug = db.Column(db.String(255), index=True) # URL-friendly identifier + content = db.Column(JSONB, nullable=False) # Rich text content (Slate.js JSON) + summary = db.Column(db.Text) # Brief description/abstract + category = db.Column(db.String(100)) # Optional categorization + + # Foreign keys + board_id = db.Column( + db.Integer, + db.ForeignKey("boards.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + created_by = db.Column( + db.Integer, + db.ForeignKey("users.id", ondelete="SET NULL"), + ) + updated_by = db.Column( + db.Integer, + db.ForeignKey("users.id", ondelete="SET NULL"), + ) + + # Timestamps + created_at = db.Column(db.DateTime, default=lambda: datetime.now(UTC)) + updated_at = db.Column( + db.DateTime, + default=lambda: datetime.now(UTC), + onupdate=lambda: datetime.now(UTC), + ) + + # JSON fields + tags = db.Column(JSONB) # List of tags for organization: ["security", "api"] + + # Relationships + board = db.relationship("Board", backref="wikis") + creator = db.relationship( + "User", foreign_keys=[created_by], backref="created_wikis" + ) + updater = db.relationship( + "User", foreign_keys=[updated_by], backref="updated_wikis" + ) + + # Linked entities relationships + linked_cards = db.relationship( + "Card", + secondary=wiki_entity_links, + primaryjoin=and_( + wiki_entity_links.c.wiki_id == id, wiki_entity_links.c.entity_type == "card" + ), + secondaryjoin=wiki_entity_links.c.entity_id == Card.id, + viewonly=True, + ) + linked_epics = db.relationship( + "Epic", + secondary=wiki_entity_links, + primaryjoin=and_( + wiki_entity_links.c.wiki_id == id, wiki_entity_links.c.entity_type == "epic" + ), + secondaryjoin=wiki_entity_links.c.entity_id == Epic.id, + viewonly=True, + ) + + def to_dict(self): + """Convert wiki to dictionary""" + return { + "id": self.id, + "name": self.name, + "slug": self.slug, + "content": self.content, + "summary": self.summary, + "category": self.category, + "board_id": self.board_id, + "tags": self.tags or [], + "created_by": self.created_by, + "updated_by": self.updated_by, + "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, + } + + def __repr__(self): + return f"" diff --git a/backend/app/routes/api.py b/backend/app/routes/api.py index 60120a8..6b289c1 100644 --- a/backend/app/routes/api.py +++ b/backend/app/routes/api.py @@ -1,12 +1,9 @@ from flask import Blueprint, jsonify, request from flask_jwt_extended import (create_access_token, create_refresh_token, get_jwt_identity, jwt_required) -from pydantic import ValidationError from app import db -from app.celery import celery -from app.models import Order, OrderItem, Product, User -from app.schemas import ProductCreateRequest, ProductResponse +from app.models import User api_bp = Blueprint("api", __name__) @@ -81,331 +78,334 @@ def get_current_user(): return jsonify(user.to_dict()), 200 -# Product Routes -@api_bp.route("/products", methods=["GET"]) -def get_products(): - """Get all products""" +# # Product Routes +# @api_bp.route("/products", methods=["GET"]) +# def get_products(): +# """Get all products""" - # time.sleep(5) # This adds a 5 second delay +# # time.sleep(5) # This adds a 5 second delay - products = Product.query.filter_by(is_active=True).all() +# products = Product.query.filter_by(is_active=True).all() - return jsonify([product.to_dict() for product in products]), 200 +# return jsonify([product.to_dict() for product in products]), 200 -@api_bp.route("/products/", methods=["GET"]) -def get_product(product_id): - """Get a single product""" - product = db.session.get(Product, product_id) - if not product: - return jsonify({"error": "Product not found"}), 404 - return jsonify(product.to_dict()), 200 +# @api_bp.route("/products/", methods=["GET"]) +# def get_product(product_id): +# """Get a single product""" +# product = db.session.get(Product, product_id) +# if not product: +# return jsonify({"error": "Product not found"}), 404 +# return jsonify(product.to_dict()), 200 -@api_bp.route("/products", methods=["POST"]) -@jwt_required() -def create_product(): - """Create a new product (admin only)""" - user_id = int(get_jwt_identity()) - user = db.session.get(User, user_id) +# @api_bp.route("/products", methods=["POST"]) +# @jwt_required() +# def create_product(): +# """Create a new product (admin only)""" +# user_id = int(get_jwt_identity()) +# user = db.session.get(User, user_id) - if not user or not user.is_admin: - return jsonify({"error": "Admin access required"}), 403 +# if not user or not user.is_admin: +# return jsonify({"error": "Admin access required"}), 403 - try: - # Validate request data using Pydantic schema - product_data = ProductCreateRequest(**request.get_json()) +# try: +# # Validate request data using Pydantic schema +# product_data = ProductCreateRequest(**request.get_json()) - product = Product( - name=product_data.name, - description=product_data.description, - price=product_data.price, - stock=product_data.stock, - image_url=product_data.image_url, - ) +# product = Product( +# name=product_data.name, +# description=product_data.description, +# price=product_data.price, +# stock=product_data.stock, +# image_url=product_data.image_url, +# ) - db.session.add(product) - db.session.commit() - - # Use Pydantic schema for response - response = ProductResponse.model_validate(product) - return jsonify(response.model_dump()), 201 - - except ValidationError as e: - print(f"Pydantic Validation Error: {e.errors()}") - return jsonify({"error": "Validation error", "details": e.errors()}), 400 +# db.session.add(product) +# db.session.commit() + +# # Use Pydantic schema for response +# response = ProductResponse.model_validate(product) +# return jsonify(response.model_dump()), 201 + +# except ValidationError as e: +# print(f"Pydantic Validation Error: {e.errors()}") +# return jsonify({"error": "Validation error", "details": e.errors()}), 400 -@api_bp.route("/products/", methods=["PUT"]) -@jwt_required() -def update_product(product_id): - """Update a product (admin only)""" - user_id = int(get_jwt_identity()) - user = db.session.get(User, user_id) - - if not user or not user.is_admin: - return jsonify({"error": "Admin access required"}), 403 - - product = db.session.get(Product, product_id) - if not product: - return jsonify({"error": "Product not found"}), 404 - - data = request.get_json() +# @api_bp.route("/products/", methods=["PUT"]) +# @jwt_required() +# def update_product(product_id): +# """Update a product (admin only)""" +# user_id = int(get_jwt_identity()) +# user = db.session.get(User, user_id) + +# if not user or not user.is_admin: +# return jsonify({"error": "Admin access required"}), 403 + +# product = db.session.get(Product, product_id) +# if not product: +# return jsonify({"error": "Product not found"}), 404 + +# data = request.get_json() - product.name = data.get("name", product.name) - product.description = data.get("description", product.description) - product.price = data.get("price", product.price) - product.stock = data.get("stock", product.stock) - product.image_url = data.get("image_url", product.image_url) - product.is_active = data.get("is_active", product.is_active) - - db.session.commit() - - return jsonify(product.to_dict()), 200 - - -@api_bp.route("/products/", methods=["DELETE"]) -@jwt_required() -def delete_product(product_id): - """Delete a product (admin only)""" - user_id = int(get_jwt_identity()) - user = db.session.get(User, user_id) - - if not user or not user.is_admin: - return jsonify({"error": "Admin access required"}), 403 - - product = db.session.get(Product, product_id) - if not product: - return jsonify({"error": "Product not found"}), 404 - - db.session.delete(product) - db.session.commit() - - return jsonify({"message": "Product deleted"}), 200 - - -# Order Routes -@api_bp.route("/orders", methods=["GET"]) -@jwt_required() -def get_orders(): - """Get all orders for current user""" - user_id = int(get_jwt_identity()) - orders = Order.query.filter_by(user_id=user_id).all() - return jsonify([order.to_dict() for order in orders]), 200 - - -@api_bp.route("/orders", methods=["POST"]) -@jwt_required() -def create_order(): - """Create a new order""" - user_id = int(get_jwt_identity()) - data = request.get_json() - - if not data or not data.get("items"): - return jsonify({"error": "Order items are required"}), 400 - - total_amount = 0 - order_items = [] - - for item_data in data["items"]: - product = db.session.get(Product, item_data["product_id"]) - if not product: - return ( - jsonify({"error": f'Product {item_data["product_id"]} not found'}), - 404, - ) - if product.stock < item_data["quantity"]: - return jsonify({"error": f"Insufficient stock for {product.name}"}), 400 - - item_total = product.price * item_data["quantity"] - total_amount += item_total - order_items.append( - { - "product": product, - "quantity": item_data["quantity"], - "price": product.price, - } - ) - - order = Order( - user_id=user_id, - total_amount=total_amount, - shipping_address=data.get("shipping_address"), - ) - - db.session.add(order) - db.session.flush() - - for item_data in order_items: - order_item = OrderItem( - order_id=order.id, - product_id=item_data["product"].id, - quantity=item_data["quantity"], - price=item_data["price"], - ) - item_data["product"].stock -= item_data["quantity"] - db.session.add(order_item) - - db.session.commit() - - return jsonify(order.to_dict()), 201 - - -@api_bp.route("/orders/", methods=["GET"]) -@jwt_required() -def get_order(order_id): - """Get a single order""" - user_id = int(get_jwt_identity()) - order = db.session.get(Order, order_id) - if not order: - return jsonify({"error": "Order not found"}), 404 - - if order.user_id != user_id: - user = db.session.get(User, user_id) - if not user or not user.is_admin: - return jsonify({"error": "Access denied"}), 403 - - return jsonify(order.to_dict()), 200 - - -# Celery Task Routes -@api_bp.route("/tasks/hello", methods=["POST"]) -@jwt_required() -def trigger_hello_task(): - """Trigger the hello task""" - data = request.get_json() or {} - name = data.get("name", "World") - - task = celery.send_task("tasks.print_hello", args=[name]) - - return ( - jsonify( - {"message": "Hello task triggered", "task_id": task.id, "status": "pending"} - ), - 202, - ) - - -@api_bp.route("/tasks/divide", methods=["POST"]) -@jwt_required() -def trigger_divide_task(): - """Trigger the divide numbers task""" - data = request.get_json() or {} - x = data.get("x", 10) - y = data.get("y", 2) - - task = celery.send_task("tasks.divide_numbers", args=[x, y]) - - return ( - jsonify( - { - "message": "Divide task triggered", - "task_id": task.id, - "operation": f"{x} / {y}", - "status": "pending", - } - ), - 202, - ) - - -@api_bp.route("/tasks/report", methods=["POST"]) -@jwt_required() -def trigger_report_task(): - """Trigger the daily report task""" - task = celery.send_task("tasks.send_daily_report") - - return ( - jsonify( - { - "message": "Daily report task triggered", - "task_id": task.id, - "status": "pending", - } - ), - 202, - ) - - -@api_bp.route("/tasks/stats", methods=["POST"]) -@jwt_required() -def trigger_stats_task(): - """Trigger product statistics update task""" - data = request.get_json() or {} - product_id = data.get("product_id") - - if product_id: - task = celery.send_task("tasks.update_product_statistics", args=[product_id]) - message = f"Product statistics update triggered for product {product_id}" - else: - task = celery.send_task("tasks.update_product_statistics", args=[None]) - message = "Product statistics update triggered for all products" - - return jsonify({"message": message, "task_id": task.id, "status": "pending"}), 202 - - -@api_bp.route("/tasks/long-running", methods=["POST"]) -@jwt_required() -def trigger_long_running_task(): - """Trigger a long-running task""" - data = request.get_json() or {} - iterations = data.get("iterations", 10) - - task = celery.send_task("tasks.long_running_task", args=[iterations]) - - return ( - jsonify( - { - "message": f"Long-running task triggered with {iterations} iterations", - "task_id": task.id, - "status": "pending", - } - ), - 202, - ) - - -@api_bp.route("/tasks/", methods=["GET"]) -@jwt_required() -def get_task_status(task_id): - """Get the status of a Celery task""" - task_result = celery.AsyncResult(task_id) - - response = { - "task_id": task_id, - "status": task_result.status, - "ready": task_result.ready(), - } - - if task_result.ready(): - if task_result.successful(): - response["result"] = task_result.result - else: - response["error"] = str(task_result.result) - response["traceback"] = task_result.traceback - - return jsonify(response), 200 - - -@api_bp.route("/tasks/health", methods=["GET"]) -def celery_health(): - """Check Celery health""" - try: - # Try to ping the worker - inspector = celery.control.inspect() - stats = inspector.stats() - - if stats: - return ( - jsonify( - {"status": "healthy", "workers": len(stats), "workers_info": stats} - ), - 200, - ) - else: - return ( - jsonify({"status": "unhealthy", "message": "No workers available"}), - 503, - ) - except Exception as e: - return jsonify({"status": "error", "message": str(e)}), 500 +# product.name = data.get("name", product.name) +# product.description = data.get("description", product.description) +# product.price = data.get("price", product.price) +# product.stock = data.get("stock", product.stock) +# product.image_url = data.get("image_url", product.image_url) +# product.is_active = data.get("is_active", product.is_active) + +# db.session.commit() + +# return jsonify(product.to_dict()), 200 + + +# @api_bp.route("/products/", methods=["DELETE"]) +# @jwt_required() +# def delete_product(product_id): +# """Delete a product (admin only)""" +# user_id = int(get_jwt_identity()) +# user = db.session.get(User, user_id) + +# if not user or not user.is_admin: +# return jsonify({"error": "Admin access required"}), 403 + +# product = db.session.get(Product, product_id) +# if not product: +# return jsonify({"error": "Product not found"}), 404 + +# db.session.delete(product) +# db.session.commit() + +# return jsonify({"message": "Product deleted"}), 200 + + +# # Order Routes +# @api_bp.route("/orders", methods=["GET"]) +# @jwt_required() +# def get_orders(): +# """Get all orders for current user""" +# user_id = int(get_jwt_identity()) +# orders = Order.query.filter_by(user_id=user_id).all() +# return jsonify([order.to_dict() for order in orders]), 200 + + +# @api_bp.route("/orders", methods=["POST"]) +# @jwt_required() +# def create_order(): +# """Create a new order""" +# user_id = int(get_jwt_identity()) +# data = request.get_json() + +# if not data or not data.get("items"): +# return jsonify({"error": "Order items are required"}), 400 + +# total_amount = 0 +# order_items = [] + +# for item_data in data["items"]: +# product = db.session.get(Product, item_data["product_id"]) +# if not product: +# return ( +# jsonify({"error": f'Product {item_data["product_id"]} not found'}), +# 404, +# ) +# if product.stock < item_data["quantity"]: +# return jsonify({"error": f"Insufficient stock for {product.name}"}), 400 + +# item_total = product.price * item_data["quantity"] +# total_amount += item_total +# order_items.append( +# { +# "product": product, +# "quantity": item_data["quantity"], +# "price": product.price, +# } +# ) + +# order = Order( +# user_id=user_id, +# total_amount=total_amount, +# shipping_address=data.get("shipping_address"), +# ) + +# db.session.add(order) +# db.session.flush() + +# for item_data in order_items: +# order_item = OrderItem( +# order_id=order.id, +# product_id=item_data["product"].id, +# quantity=item_data["quantity"], +# price=item_data["price"], +# ) +# item_data["product"].stock -= item_data["quantity"] +# db.session.add(order_item) + +# db.session.commit() + +# return jsonify(order.to_dict()), 201 + + +# @api_bp.route("/orders/", methods=["GET"]) +# @jwt_required() +# def get_order(order_id): +# """Get a single order""" +# user_id = int(get_jwt_identity()) +# order = db.session.get(Order, order_id) +# if not order: +# return jsonify({"error": "Order not found"}), 404 + +# if order.user_id != user_id: +# user = db.session.get(User, user_id) +# if not user or not user.is_admin: +# return jsonify({"error": "Access denied"}), 403 + +# return jsonify(order.to_dict()), 200 + + +# # Celery Task Routes +# @api_bp.route("/tasks/hello", methods=["POST"]) +# @jwt_required() +# def trigger_hello_task(): +# """Trigger the hello task""" +# data = request.get_json() or {} +# name = data.get("name", "World") + +# task = celery.send_task("tasks.print_hello", args=[name]) + +# return ( +# jsonify( +# {"message": "Hello task triggered", "task_id": task.id, "status": +# "pending"} +# ), +# 202, +# ) + + +# @api_bp.route("/tasks/divide", methods=["POST"]) +# @jwt_required() +# def trigger_divide_task(): +# """Trigger the divide numbers task""" +# data = request.get_json() or {} +# x = data.get("x", 10) +# y = data.get("y", 2) + +# task = celery.send_task("tasks.divide_numbers", args=[x, y]) + +# return ( +# jsonify( +# { +# "message": "Divide task triggered", +# "task_id": task.id, +# "operation": f"{x} / {y}", +# "status": "pending", +# } +# ), +# 202, +# ) + + +# @api_bp.route("/tasks/report", methods=["POST"]) +# @jwt_required() +# def trigger_report_task(): +# """Trigger the daily report task""" +# task = celery.send_task("tasks.send_daily_report") + +# return ( +# jsonify( +# { +# "message": "Daily report task triggered", +# "task_id": task.id, +# "status": "pending", +# } +# ), +# 202, +# ) + + +# @api_bp.route("/tasks/stats", methods=["POST"]) +# @jwt_required() +# def trigger_stats_task(): +# """Trigger product statistics update task""" +# data = request.get_json() or {} +# product_id = data.get("product_id") + +# if product_id: +# task = celery.send_task("tasks.update_product_statistics", args=[product_id]) +# message = f"Product statistics update triggered for product {product_id}" +# else: +# task = celery.send_task("tasks.update_product_statistics", args=[None]) +# message = "Product statistics update triggered for all products" + +# return jsonify({"message": message, "task_id": task.id, "status": "pending"}), 202 + + +# @api_bp.route("/tasks/long-running", methods=["POST"]) +# @jwt_required() +# def trigger_long_running_task(): +# """Trigger a long-running task""" +# data = request.get_json() or {} +# iterations = data.get("iterations", 10) + +# task = celery.send_task("tasks.long_running_task", args=[iterations]) + +# return ( +# jsonify( +# { +# "message": f"Long-running task triggered with {iterations} +# iterations", +# "task_id": task.id, +# "status": "pending", +# } +# ), +# 202, +# ) + + +# @api_bp.route("/tasks/", methods=["GET"]) +# @jwt_required() +# def get_task_status(task_id): +# """Get the status of a Celery task""" +# task_result = celery.AsyncResult(task_id) + +# response = { +# "task_id": task_id, +# "status": task_result.status, +# "ready": task_result.ready(), +# } + +# if task_result.ready(): +# if task_result.successful(): +# response["result"] = task_result.result +# else: +# response["error"] = str(task_result.result) +# response["traceback"] = task_result.traceback + +# return jsonify(response), 200 + + +# @api_bp.route("/tasks/health", methods=["GET"]) +# def celery_health(): +# """Check Celery health""" +# try: +# # Try to ping the worker +# inspector = celery.control.inspect() +# stats = inspector.stats() + +# if stats: +# return ( +# jsonify( +# {"status": "healthy", "workers": len(stats), "workers_info": +# stats} +# ), +# 200, +# ) +# else: +# return ( +# jsonify({"status": "unhealthy", "message": "No workers available"}), +# 503, +# ) +# except Exception as e: +# return jsonify({"status": "error", "message": str(e)}), 500 diff --git a/backend/app/routes/kanban/__init__.py b/backend/app/routes/kanban/__init__.py index ea98768..a9fe728 100644 --- a/backend/app/routes/kanban/__init__.py +++ b/backend/app/routes/kanban/__init__.py @@ -6,8 +6,8 @@ kanban_bp = Blueprint("kanban", __name__) # Import all route modules to register their routes to this blueprint # fmt: off -from . import (boards, cards, checklists, comments, files, # noqa: F401 E402 - labels, lists) +from . import (boards, cards, checklists, comments, epics, # noqa: F401 E402 + files, labels, lists, wikis) # fmt: on diff --git a/backend/app/routes/kanban/boards.py b/backend/app/routes/kanban/boards.py index 05c6455..7953571 100644 --- a/backend/app/routes/kanban/boards.py +++ b/backend/app/routes/kanban/boards.py @@ -64,6 +64,12 @@ def get_board(board_id, board): for checklist in card.checklists.all() ] + # Add epic for this card + if card.epic: + card_dict["epic"] = card.epic.to_dict() + else: + card_dict["epic"] = None + cards_data.append(card_dict) lists_data.append({**lst.to_dict(), "cards": cards_data}) diff --git a/backend/app/routes/kanban/cards.py b/backend/app/routes/kanban/cards.py index 52833d2..1ddaed6 100644 --- a/backend/app/routes/kanban/cards.py +++ b/backend/app/routes/kanban/cards.py @@ -77,6 +77,12 @@ def get_card(card_id, card): comment_dict["user"] = user.to_dict() if user else None card_dict["comments"].append(comment_dict) + # Add epic + if card.epic: + card_dict["epic"] = card.epic.to_dict() + else: + card_dict["epic"] = None + response = CardWithDetailsResponse(**card_dict) return response.model_dump(), 200 diff --git a/backend/app/routes/kanban/epics.py b/backend/app/routes/kanban/epics.py new file mode 100644 index 0000000..01ede3a --- /dev/null +++ b/backend/app/routes/kanban/epics.py @@ -0,0 +1,164 @@ +"""Routes for Epic CRUD operations.""" + +from flask import request +from flask_jwt_extended import jwt_required +from flask_pydantic import validate + +from app import db +from app.models import Board, Card, Epic +from app.schemas import EpicCreateRequest, EpicResponse, EpicUpdateRequest + +from . import kanban_bp + + +@kanban_bp.route("/boards//epics", methods=["GET"]) +@jwt_required() +def get_board_epics(board_id): + """Get all epics for a board.""" + board = db.session.get(Board, board_id) + if not board: + return {"error": "Board not found"}, 404 + + epics = Epic.query.filter_by(board_id=board_id).all() + return [EpicResponse.model_validate(epic).model_dump() for epic in epics], 200 + + +@kanban_bp.route("/boards//epics", methods=["POST"]) +@jwt_required() +@validate(body=EpicCreateRequest) +def create_epic(board_id, body: EpicCreateRequest): + """Create a new epic for a board.""" + board = db.session.get(Board, board_id) + if not board: + return {"error": "Board not found"}, 404 + + epic = Epic( + board_id=board_id, + name=body.name, + description=body.description, + content=body.content, + color=body.color, + closed=False, + pos=body.pos, + depth_limit=body.depth_limit, + parent_epic_id=body.parent_epic_id, + completed_list_id=body.completed_list_id, + metrics={"card_count": 0, "completed_cards_count": 0}, + ) + + db.session.add(epic) + db.session.commit() + + return EpicResponse.model_validate(epic).model_dump(), 201 + + +@kanban_bp.route("/epics/", methods=["GET"]) +@jwt_required() +def get_epic(epic_id): + """Get a specific epic with details.""" + epic = db.session.get(Epic, epic_id) + if not epic: + return {"error": "Epic not found"}, 404 + + # Get cards for this epic + cards = Card.query.filter_by(epic_id=epic_id).all() + epic_dict = EpicResponse.model_validate(epic).model_dump() + epic_dict["cards"] = [card.to_dict() for card in cards] + + return epic_dict, 200 + + +def _update_epic_fields(epic: Epic, body: EpicUpdateRequest) -> None: + """Update epic fields from request body.""" + if body.name is not None: + epic.name = body.name + if body.description is not None: + epic.description = body.description + if body.content is not None: + epic.content = body.content + if body.color is not None: + epic.color = body.color + if body.closed is not None: + epic.closed = body.closed + if body.pos is not None: + epic.pos = body.pos + if body.depth_limit is not None: + epic.depth_limit = body.depth_limit + if body.parent_epic_id is not None: + epic.parent_epic_id = body.parent_epic_id + if body.completed_list_id is not None: + epic.completed_list_id = body.completed_list_id + + +@kanban_bp.route("/epics/", methods=["PUT"]) +@jwt_required() +@validate(body=EpicUpdateRequest) +def update_epic(epic_id, body: EpicUpdateRequest): + """Update an epic.""" + epic = db.session.get(Epic, epic_id) + if not epic: + return {"error": "Epic not found"}, 404 + + _update_epic_fields(epic, body) + db.session.commit() + + return EpicResponse.model_validate(epic).model_dump(), 200 + + +@kanban_bp.route("/epics/", methods=["DELETE"]) +@jwt_required() +def delete_epic(epic_id): + """Delete an epic.""" + epic = db.session.get(Epic, epic_id) + if not epic: + return {"error": "Epic not found"}, 404 + + # Unlink all cards from this epic + Card.query.filter_by(epic_id=epic_id).update({"epic_id": None}) + + # Delete epic + db.session.delete(epic) + db.session.commit() + + return {"message": "Epic deleted successfully"}, 200 + + +@kanban_bp.route("/cards//epics", methods=["POST"]) +@jwt_required() +def add_epic_to_card(card_id): + """Link an epic to a card.""" + card = db.session.get(Card, card_id) + if not card: + return {"error": "Card not found"}, 404 + + data = request.get_json() + epic_id = data.get("epic_id") + + if not epic_id: + return {"error": "epic_id is required"}, 400 + + epic = db.session.get(Epic, epic_id) + if not epic: + return {"error": "Epic not found"}, 404 + + card.epic_id = epic_id + db.session.commit() + + return card.to_dict(), 200 + + +@kanban_bp.route("/cards//epics/", methods=["DELETE"]) +@jwt_required() +def remove_epic_from_card(card_id, epic_id): + """Unlink an epic from a card.""" + card = db.session.get(Card, card_id) + if not card: + return {"error": "Card not found"}, 404 + + if card.epic_id != epic_id: + return {"error": "Card is not linked to this epic"}, 400 + + card.epic_id = None + db.session.commit() + + return {"message": "Epic unlinked from card successfully"}, 200 diff --git a/backend/app/routes/kanban/wikis.py b/backend/app/routes/kanban/wikis.py new file mode 100644 index 0000000..4a3d2b6 --- /dev/null +++ b/backend/app/routes/kanban/wikis.py @@ -0,0 +1,228 @@ +"""Routes for Wiki CRUD operations.""" + +import re + +from flask_jwt_extended import get_jwt_identity, jwt_required +from flask_pydantic import validate +from sqlalchemy import and_ +from sqlalchemy import delete as sql_delete +from sqlalchemy import select + +from app import db +from app.models import Board, Wiki, wiki_entity_links +from app.models.card import Card +from app.models.epic import Epic +from app.schemas import (CardResponse, EpicResponse, WikiCreateRequest, + WikiLinkRequest, WikiResponse, WikiUpdateRequest) + +from . import kanban_bp + + +def generate_slug(name): + """Generate URL-friendly slug from name.""" + slug = name.lower() + slug = re.sub(r"[^a-z0-9\s-]", "", slug) + slug = re.sub(r"\s+", "-", slug) + slug = slug.strip("-") + return slug + + +@kanban_bp.route("/boards//wikis", methods=["GET"]) +@jwt_required() +def get_board_wikis(board_id): + """Get all wikis for a board.""" + board = db.session.get(Board, board_id) + if not board: + return {"error": "Board not found"}, 404 + + wikis = Wiki.query.filter_by(board_id=board_id).all() + return [WikiResponse.model_validate(wiki).model_dump() for wiki in wikis], 200 + + +@kanban_bp.route("/boards//wikis", methods=["POST"]) +@jwt_required() +@validate(body=WikiCreateRequest) +def create_wiki(board_id, body: WikiCreateRequest): + """Create a new wiki for a board.""" + board = db.session.get(Board, board_id) + if not board: + return {"error": "Board not found"}, 404 + + # Generate slug if not provided + slug = body.slug + if not slug: + slug = generate_slug(body.name) + + # Ensure uniqueness + counter = 1 + original_slug = slug + while Wiki.query.filter_by(slug=slug).first(): + slug = f"{original_slug}-{counter}" + counter += 1 + + wiki = Wiki( + board_id=board_id, + name=body.name, + slug=slug, + content=body.content, + summary=body.summary, + category=body.category, + tags=body.tags, + created_by=get_jwt_identity(), + updated_by=get_jwt_identity(), + ) + + db.session.add(wiki) + db.session.commit() + + return WikiResponse.model_validate(wiki).model_dump(), 201 + + +@kanban_bp.route("/wikis/", methods=["GET"]) +@jwt_required() +def get_wiki(wiki_id): + """Get a specific wiki with details including linked entities.""" + wiki = db.session.get(Wiki, wiki_id) + if not wiki: + return {"error": "Wiki not found"}, 404 + + # Convert wiki to dict and add linked entities + wiki_dict = wiki.to_dict() + + # Add linked cards + linked_cards = [ + CardResponse.model_validate(card).model_dump() for card in wiki.linked_cards + ] + + # Add linked epics + linked_epics = [ + EpicResponse.model_validate(epic).model_dump() for epic in wiki.linked_epics + ] + + wiki_dict["linked_cards"] = linked_cards + wiki_dict["linked_epics"] = linked_epics + + return wiki_dict, 200 + + +@kanban_bp.route("/wikis/", methods=["PUT"]) +@jwt_required() +@validate(body=WikiUpdateRequest) +def update_wiki(wiki_id, body: WikiUpdateRequest): + """Update a wiki.""" + wiki = db.session.get(Wiki, wiki_id) + if not wiki: + return {"error": "Wiki not found"}, 404 + + # Update fields + if body.name is not None: + wiki.name = body.name + if body.slug is not None: + wiki.slug = body.slug + if body.content is not None: + wiki.content = body.content + if body.summary is not None: + wiki.summary = body.summary + if body.category is not None: + wiki.category = body.category + if body.tags is not None: + wiki.tags = body.tags + + wiki.updated_by = get_jwt_identity() + db.session.commit() + + return WikiResponse.model_validate(wiki).model_dump(), 200 + + +@kanban_bp.route("/wikis/", methods=["DELETE"]) +@jwt_required() +def delete_wiki(wiki_id): + """Delete a wiki.""" + wiki = db.session.get(Wiki, wiki_id) + if not wiki: + return {"error": "Wiki not found"}, 404 + + # Delete wiki (cascades to wiki_entity_links) + db.session.delete(wiki) + db.session.commit() + + return {"message": "Wiki deleted successfully"}, 200 + + +@kanban_bp.route("/wikis//links", methods=["POST"]) +@jwt_required() +@validate(body=WikiLinkRequest) +def create_wiki_link(wiki_id, body: WikiLinkRequest): + """Link a wiki to another entity (card, epic, list, or board).""" + wiki = db.session.get(Wiki, wiki_id) + if not wiki: + return {"error": "Wiki not found"}, 404 + + # Check if entity exists + entity = None + if body.entity_type == "card": + entity = db.session.get(Card, body.entity_id) + elif body.entity_type == "epic": + entity = db.session.get(Epic, body.entity_id) + elif body.entity_type == "list": + from app.models.list_model import List + + entity = db.session.get(List, body.entity_id) + elif body.entity_type == "board": + entity = db.session.get(Board, body.entity_id) + + if not entity: + return {"error": f"{body.entity_type.capitalize()} not found"}, 404 + + # Check if link already exists + existing_link = db.session.scalars( + select(wiki_entity_links).where( + and_( + wiki_entity_links.c.wiki_id == wiki_id, + wiki_entity_links.c.entity_type == body.entity_type, + wiki_entity_links.c.entity_id == body.entity_id, + ) + ) + ).first() + + if existing_link: + return {"error": "Link already exists"}, 400 + + # Create link using core insert + stmt = wiki_entity_links.insert().values( + wiki_id=wiki_id, + entity_type=body.entity_type, + entity_id=body.entity_id, + linked_by=get_jwt_identity(), + ) + db.session.execute(stmt) + db.session.commit() + + return {"message": "Link created successfully"}, 201 + + +@kanban_bp.route("/wikis//links", methods=["DELETE"]) +@jwt_required() +@validate(body=WikiLinkRequest) +def delete_wiki_link(wiki_id, body: WikiLinkRequest): + """Delete a wiki entity link.""" + wiki = db.session.get(Wiki, wiki_id) + if not wiki: + return {"error": "Wiki not found"}, 404 + + # Delete link using core delete + stmt = sql_delete(wiki_entity_links).where( + and_( + wiki_entity_links.c.wiki_id == wiki_id, + wiki_entity_links.c.entity_type == body.entity_type, + wiki_entity_links.c.entity_id == body.entity_id, + ) + ) + result = db.session.execute(stmt) + + if result.rowcount == 0: + return {"error": "Link not found"}, 404 + + db.session.commit() + + return {"message": "Link deleted successfully"}, 200 diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index 6b2c18e..47c2fe0 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -7,6 +7,8 @@ from app.schemas.check_item import CheckItemCreateRequest, CheckItemResponse from app.schemas.checklist import ChecklistCreateRequest, ChecklistResponse from app.schemas.comment import (CommentCreateRequest, CommentResponse, CommentWithUserResponse) +from app.schemas.epic import (EpicCreateRequest, EpicResponse, + EpicUpdateRequest, EpicWithCardsResponse) from app.schemas.file_attachment import (FileAttachmentWithUrlResponse, FileDeleteResponse, FileUploadRequest, MultipleFilesResponse, @@ -14,6 +16,8 @@ from app.schemas.file_attachment import (FileAttachmentWithUrlResponse, from app.schemas.label import LabelCreateRequest, LabelResponse from app.schemas.list_model import ListCreateRequest, ListResponse from app.schemas.product import ProductCreateRequest, ProductResponse +from app.schemas.wiki import (WikiCreateRequest, WikiEntityLink, + WikiLinkRequest, WikiResponse, WikiUpdateRequest) __all__ = [ # Product schemas @@ -50,4 +54,15 @@ __all__ = [ "BoardWithDetailsResponse", "CardWithDetailsResponse", "CommentWithUserResponse", + # Epic schemas + "EpicCreateRequest", + "EpicResponse", + "EpicUpdateRequest", + "EpicWithCardsResponse", + # Wiki schemas + "WikiCreateRequest", + "WikiResponse", + "WikiUpdateRequest", + "WikiEntityLink", + "WikiLinkRequest", ] diff --git a/backend/app/schemas/card.py b/backend/app/schemas/card.py index 497bff6..7a92870 100644 --- a/backend/app/schemas/card.py +++ b/backend/app/schemas/card.py @@ -115,3 +115,4 @@ class CardWithDetailsResponse(BaseModel): labels: List[Dict[str, Any]] = Field(default_factory=list) checklists: List[Dict[str, Any]] = Field(default_factory=list) comments: List[Dict[str, Any]] = Field(default_factory=list) + epic: Optional[Dict[str, Any]] = None diff --git a/backend/app/schemas/epic.py b/backend/app/schemas/epic.py new file mode 100644 index 0000000..9936dde --- /dev/null +++ b/backend/app/schemas/epic.py @@ -0,0 +1,139 @@ +"""Pydantic schemas for Epic model""" +from datetime import datetime +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel, ConfigDict, Field + + +class EpicCreateRequest(BaseModel): + """Schema for creating a new epic""" + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "name": "User Authentication", + "description": "Implement login and registration", + "content": [ + {"type": "paragraph", "children": [{"text": "Details..."}]} + ], + "color": "#3b82f6", + "pos": 1.0, + "depth_limit": 5, + "parent_epic_id": None, + "completed_list_id": 5, + } + } + ) + + name: str = Field(..., min_length=1, max_length=200, description="Epic name") + description: Optional[str] = Field(None, description="Epic description") + content: Optional[Any] = Field(None, description="Rich text content") + color: Optional[str] = Field(None, max_length=7, description="Hex color code") + pos: Optional[float] = Field(None, description="Position for ordering") + depth_limit: Optional[int] = Field( + None, ge=1, le=10, description="Maximum depth for hierarchy" + ) + parent_epic_id: Optional[int] = Field( + None, description="Parent epic ID for nesting" + ) + completed_list_id: Optional[int] = Field( + None, description="List ID that marks cards as completed" + ) + + +class EpicResponse(BaseModel): + """Schema for epic response""" + + model_config = ConfigDict( + from_attributes=True, + json_schema_extra={ + "example": { + "id": 1, + "name": "User Authentication", + "description": "Implement login and registration", + "content": [ + {"type": "paragraph", "children": [{"text": "Details..."}]} + ], + "color": "#3b82f6", + "closed": False, + "pos": 1.0, + "depth_limit": 5, + "board_id": 1, + "parent_epic_id": None, + "metrics": {"card_count": 5, "progress": 60}, + "date_last_activity": "2024-01-15T10:30:00", + "created_at": "2024-01-01T10:00:00", + "updated_at": "2024-01-15T10:30:00", + } + }, + ) + + id: int + name: str + description: Optional[str] = None + content: Optional[Any] = None + color: Optional[str] = None + closed: bool + pos: Optional[float] = None + depth_limit: Optional[int] = None + board_id: int + parent_epic_id: Optional[int] = None + completed_list_id: Optional[int] = None + metrics: Optional[Dict[str, Any]] = None + date_last_activity: Optional[datetime] = None + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + + +class EpicUpdateRequest(BaseModel): + """Schema for updating an epic""" + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "name": "Updated Epic Name", + "description": "Updated description", + "content": [ + {"type": "paragraph", "children": [{"text": "Updated..."}]} + ], + "color": "#ef4444", + "closed": True, + "pos": 2.0, + "depth_limit": 3, + "parent_epic_id": 2, + "completed_list_id": 5, + } + } + ) + + name: Optional[str] = Field(None, min_length=1, max_length=200) + description: Optional[str] = None + content: Optional[Any] = None + color: Optional[str] = Field(None, max_length=7) + closed: Optional[bool] = None + pos: Optional[float] = None + depth_limit: Optional[int] = Field(None, ge=1, le=10) + parent_epic_id: Optional[int] = None + completed_list_id: Optional[int] = None + + +class EpicWithCardsResponse(BaseModel): + """Schema for epic response with cards""" + + model_config = ConfigDict(from_attributes=True) + + id: int + name: str + description: Optional[str] = None + content: Optional[Any] = None + color: Optional[str] = None + closed: bool + pos: Optional[float] = None + depth_limit: Optional[int] = None + board_id: int + parent_epic_id: Optional[int] = None + metrics: Optional[Dict[str, Any]] = None + date_last_activity: Optional[datetime] = None + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + cards: List[Dict[str, Any]] = Field(default_factory=list) diff --git a/backend/app/schemas/wiki.py b/backend/app/schemas/wiki.py new file mode 100644 index 0000000..27f349e --- /dev/null +++ b/backend/app/schemas/wiki.py @@ -0,0 +1,174 @@ +"""Pydantic schemas for Wiki model""" +from datetime import datetime +from typing import Any, List, Optional + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from app.schemas.card import CardResponse +from app.schemas.epic import EpicResponse + + +class WikiCreateRequest(BaseModel): + """Schema for creating a new wiki""" + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "name": "Security Guidelines", + "slug": "security-guidelines", + "content": [ + {"type": "paragraph", "children": [{"text": "Security rules..."}]} + ], + "summary": "Brief security overview", + "category": "Documentation", + "tags": ["security", "guidelines"], + } + } + ) + + name: str = Field(..., min_length=1, max_length=200, description="Wiki name") + slug: Optional[str] = Field(None, max_length=200, description="URL-friendly slug") + content: Any = Field(..., description="Rich text content") + summary: Optional[str] = Field(None, description="Brief summary") + category: Optional[str] = Field(None, max_length=100, description="Wiki category") + tags: Optional[List[str]] = Field(None, description="Wiki tags") + + @field_validator("slug") + @classmethod + def validate_slug(cls, v): + """Validate slug is URL-friendly.""" + if v and not v.islower(): + raise ValueError("Slug must be lowercase") + if v and not all(c.isalnum() or c == "-" for c in v): + raise ValueError( + "Slug must contain only lowercase letters, numbers, and hyphens" + ) + return v + + +class WikiResponse(BaseModel): + """Schema for wiki response""" + + model_config = ConfigDict( + from_attributes=True, + json_schema_extra={ + "example": { + "id": 1, + "name": "Security Guidelines", + "slug": "security-guidelines", + "content": [ + {"type": "paragraph", "children": [{"text": "Security rules..."}]} + ], + "summary": "Brief security overview", + "category": "Documentation", + "tags": ["security", "guidelines"], + "board_id": 1, + "created_by": 1, + "updated_by": 1, + "created_at": "2024-01-01T10:00:00", + "updated_at": "2024-01-15T10:30:00", + "linked_cards": [], + "linked_epics": [], + } + }, + ) + + id: int + name: str + slug: Optional[str] = None + content: Any + summary: Optional[str] = None + category: Optional[str] = None + tags: Optional[List[str]] = None + board_id: int + created_by: Optional[int] = None + updated_by: Optional[int] = None + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + linked_cards: Optional[List[CardResponse]] = None + linked_epics: Optional[List[EpicResponse]] = None + + +class WikiUpdateRequest(BaseModel): + """Schema for updating a wiki""" + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "name": "Updated Security Guidelines", + "slug": "updated-security-guidelines", + "content": [ + {"type": "paragraph", "children": [{"text": "Updated..."}]} + ], + "summary": "Updated summary", + "category": "Updated Category", + "tags": ["security", "updated"], + } + } + ) + + name: Optional[str] = Field(None, min_length=1, max_length=200) + slug: Optional[str] = Field(None, max_length=200) + content: Optional[Any] = None + summary: Optional[str] = None + category: Optional[str] = Field(None, max_length=100) + tags: Optional[List[str]] = None + + @field_validator("slug") + @classmethod + def validate_slug(cls, v): + """Validate slug is URL-friendly.""" + if v and not v.islower(): + raise ValueError("Slug must be lowercase") + if v and not all(c.isalnum() or c == "-" for c in v): + raise ValueError( + "Slug must contain only lowercase letters, numbers, and hyphens" + ) + return v + + +class WikiEntityLink(BaseModel): + """Schema for wiki entity link response""" + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "wiki_id": 1, + "entity_type": "card", + "entity_id": 5, + "created_at": "2024-01-15T10:30:00", + "linked_by": 1, + } + } + ) + + wiki_id: int + entity_type: str + entity_id: int + created_at: Optional[datetime] = None + linked_by: Optional[int] = None + + +class WikiLinkRequest(BaseModel): + """Schema for creating a wiki entity link""" + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "entity_type": "card", + "entity_id": 5, + } + } + ) + + entity_type: str = Field(..., description="Entity type (card, epic, list, board)") + entity_id: int = Field(..., description="Entity ID") + + @field_validator("entity_type") + @classmethod + def validate_entity_type(cls, v): + """Validate entity type is supported.""" + valid_types = ["card", "epic", "list", "board"] + if v not in valid_types: + raise ValueError(f"entity_type must be one of: {', '.join(valid_types)}") + return v diff --git a/backend/app/services/storage/storage_extension.py b/backend/app/services/storage/storage_extension.py index 432abe8..54d0dfa 100644 --- a/backend/app/services/storage/storage_extension.py +++ b/backend/app/services/storage/storage_extension.py @@ -54,6 +54,7 @@ class StorageExtension: access_key=app.config["MINIO_ACCESS_KEY"], secret_key=app.config["MINIO_SECRET_KEY"], secure=app.config["MINIO_USE_SSL"], + region=app.config["MINIO_REGION"], ) # Validate connection by listing buckets @@ -93,17 +94,11 @@ class StorageExtension: ] for bucket_name in buckets: - try: - if not self.client.bucket_exists(bucket_name): - self.client.make_bucket(bucket_name) - app.logger.info(f"Created MinIO bucket: {bucket_name}") - else: - app.logger.debug(f"MinIO bucket already exists: {bucket_name}") - except S3Error as e: - app.logger.error(f"Failed to ensure bucket {bucket_name}: {e}") - raise RuntimeError( - f"Failed to create MinIO bucket '{bucket_name}': {e}" - ) from e + if self.client.bucket_exists(bucket_name): + app.logger.debug(f"MinIO bucket exists: {bucket_name}") + else: + app.logger.error(f"MinIO bucket does not exists: {bucket_name}") + raise RuntimeError(f"Failed to create MinIO bucket '{bucket_name}'") def get_client(self) -> Minio: """ diff --git a/backend/migrations/versions/6fc439155ced_add_epic_and_wiki_models.py b/backend/migrations/versions/6fc439155ced_add_epic_and_wiki_models.py new file mode 100644 index 0000000..b1bbc55 --- /dev/null +++ b/backend/migrations/versions/6fc439155ced_add_epic_and_wiki_models.py @@ -0,0 +1,106 @@ +"""add epic and wiki models + +Revision ID: 6fc439155ced +Revises: e9515e29ef8b +Create Date: 2026-03-22 00:24:26.645867 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '6fc439155ced' +down_revision = 'e9515e29ef8b' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('epics', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=200), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('content', postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column('color', sa.String(length=7), nullable=True), + sa.Column('closed', sa.Boolean(), nullable=True), + sa.Column('pos', sa.Float(), nullable=True), + sa.Column('depth_limit', sa.Integer(), nullable=True), + sa.Column('board_id', sa.Integer(), nullable=False), + sa.Column('parent_epic_id', sa.Integer(), nullable=True), + sa.Column('date_last_activity', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.Column('metrics', postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.ForeignKeyConstraint(['board_id'], ['boards.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['parent_epic_id'], ['epics.id'], ondelete='SET NULL'), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('epics', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_epics_board_id'), ['board_id'], unique=False) + batch_op.create_index(batch_op.f('ix_epics_closed'), ['closed'], unique=False) + batch_op.create_index(batch_op.f('ix_epics_name'), ['name'], unique=False) + + op.create_table('wikis', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=200), nullable=False), + sa.Column('slug', sa.String(length=255), nullable=True), + sa.Column('content', postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.Column('summary', sa.Text(), nullable=True), + sa.Column('category', sa.String(length=100), nullable=True), + sa.Column('board_id', sa.Integer(), nullable=False), + sa.Column('created_by', sa.Integer(), nullable=True), + sa.Column('updated_by', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.Column('tags', postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.ForeignKeyConstraint(['board_id'], ['boards.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['created_by'], ['users.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['updated_by'], ['users.id'], ondelete='SET NULL'), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('wikis', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_wikis_board_id'), ['board_id'], unique=False) + batch_op.create_index(batch_op.f('ix_wikis_name'), ['name'], unique=False) + batch_op.create_index(batch_op.f('ix_wikis_slug'), ['slug'], unique=False) + + op.create_table('wiki_entity_links', + sa.Column('wiki_id', sa.Integer(), nullable=False), + sa.Column('entity_type', sa.String(length=50), nullable=False), + sa.Column('entity_id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('linked_by', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['linked_by'], ['users.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['wiki_id'], ['wikis.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('wiki_id') + ) + with op.batch_alter_table('cards', schema=None) as batch_op: + batch_op.add_column(sa.Column('epic_id', sa.Integer(), nullable=True)) + batch_op.create_index(batch_op.f('ix_cards_epic_id'), ['epic_id'], unique=False) + batch_op.create_foreign_key(None, 'epics', ['epic_id'], ['id'], ondelete='SET NULL') + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('cards', schema=None) as batch_op: + batch_op.drop_constraint(None, type_='foreignkey') + batch_op.drop_index(batch_op.f('ix_cards_epic_id')) + batch_op.drop_column('epic_id') + + op.drop_table('wiki_entity_links') + with op.batch_alter_table('wikis', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_wikis_slug')) + batch_op.drop_index(batch_op.f('ix_wikis_name')) + batch_op.drop_index(batch_op.f('ix_wikis_board_id')) + + op.drop_table('wikis') + with op.batch_alter_table('epics', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_epics_name')) + batch_op.drop_index(batch_op.f('ix_epics_closed')) + batch_op.drop_index(batch_op.f('ix_epics_board_id')) + + op.drop_table('epics') + # ### end Alembic commands ### diff --git a/backend/migrations/versions/84551733ccb0_epic_add_completion_list_id.py b/backend/migrations/versions/84551733ccb0_epic_add_completion_list_id.py new file mode 100644 index 0000000..d96e93b --- /dev/null +++ b/backend/migrations/versions/84551733ccb0_epic_add_completion_list_id.py @@ -0,0 +1,36 @@ +"""epic_add completion list id + +Revision ID: 84551733ccb0 +Revises: 6fc439155ced +Create Date: 2026-03-28 21:54:31.562023 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '84551733ccb0' +down_revision = '6fc439155ced' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('epics', schema=None) as batch_op: + batch_op.add_column(sa.Column('completed_list_id', sa.Integer(), nullable=True)) + batch_op.create_index(batch_op.f('ix_epics_completed_list_id'), ['completed_list_id'], unique=False) + batch_op.create_foreign_key(None, 'lists', ['completed_list_id'], ['id'], ondelete='SET NULL') + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('epics', schema=None) as batch_op: + batch_op.drop_constraint(None, type_='foreignkey') + batch_op.drop_index(batch_op.f('ix_epics_completed_list_id')) + batch_op.drop_column('completed_list_id') + + # ### end Alembic commands ### diff --git a/backend/tests/routes/test_epics.py b/backend/tests/routes/test_epics.py new file mode 100644 index 0000000..f137ce3 --- /dev/null +++ b/backend/tests/routes/test_epics.py @@ -0,0 +1,702 @@ +"""Integration tests for Epic API routes""" +import pytest + +from app import db +from app.models import Epic + + +@pytest.mark.integration +class TestEpicRoutes: + """Test Epic API routes""" + + # ========== GET /boards//epics ========== + + def test_get_board_epics_success( + self, client, db_session, auth_headers, test_board + ): + """Test getting all epics for a board""" + # Create epics for the board + epic1 = Epic(name="Epic 1", board_id=test_board.id, color="#3b82f6", pos=1.0) + epic2 = Epic(name="Epic 2", board_id=test_board.id, color="#ef4444", pos=2.0) + db_session.add(epic1) + db_session.add(epic2) + db_session.commit() + + response = client.get( + f"/api/boards/{test_board.id}/epics", headers=auth_headers + ) + + assert response.status_code == 200 + data = response.get_json() + assert len(data) == 2 + assert data[0]["name"] == "Epic 1" + assert data[1]["name"] == "Epic 2" + + def test_get_board_epics_empty(self, client, db_session, auth_headers, test_board): + """Test getting epics when none exist for board""" + response = client.get( + f"/api/boards/{test_board.id}/epics", headers=auth_headers + ) + + assert response.status_code == 200 + data = response.get_json() + assert len(data) == 0 + + def test_get_board_epics_board_not_found(self, client, db_session, auth_headers): + """Test getting epics for non-existent board""" + response = client.get("/api/boards/99999/epics", headers=auth_headers) + + assert response.status_code == 404 + data = response.get_json() + assert "not found" in data["error"].lower() + + def test_get_board_epics_unauthorized(self, client, db_session, test_board): + """Test getting epics without authentication""" + response = client.get(f"/api/boards/{test_board.id}/epics") + + assert response.status_code == 401 + + # ========== POST /boards//epics ========== + + def test_create_epic_success( + self, client, db_session, regular_user, auth_headers, test_board + ): + """Test creating a new epic successfully""" + epic_data = { + "name": "User Authentication", + "description": "Implement login and registration", + "content": [{"type": "paragraph", "children": [{"text": "Details..."}]}], + "color": "#3b82f6", + "pos": 1.0, + "depth_limit": 5, + } + + response = client.post( + f"/api/boards/{test_board.id}/epics", + headers=auth_headers, + json=epic_data, + ) + + assert response.status_code == 201 + data = response.get_json() + assert data["name"] == "User Authentication" + assert data["description"] == "Implement login and registration" + assert data["color"] == "#3b82f6" + assert data["pos"] == 1.0 + assert data["depth_limit"] == 5 + assert data["closed"] is False + assert "id" in data + assert data["metrics"] == {"card_count": 0, "completed_cards_count": 0} + + def test_create_epic_minimal_data( + self, client, db_session, auth_headers, test_board + ): + """Test creating epic with only required fields""" + epic_data = {"name": "Minimal Epic"} + + response = client.post( + f"/api/boards/{test_board.id}/epics", + headers=auth_headers, + json=epic_data, + ) + + assert response.status_code == 201 + data = response.get_json() + assert data["name"] == "Minimal Epic" + assert data["description"] is None + assert data["color"] is None + assert data["pos"] is None + assert data["depth_limit"] == 5 # Model default value + + def test_create_epic_with_parent( + self, client, db_session, auth_headers, test_board + ): + """Test creating epic with parent epic""" + # Create parent epic + parent_epic = Epic(name="Parent Epic", board_id=test_board.id) + db_session.add(parent_epic) + db_session.commit() + + epic_data = { + "name": "Child Epic", + "parent_epic_id": parent_epic.id, + } + + response = client.post( + f"/api/boards/{test_board.id}/epics", + headers=auth_headers, + json=epic_data, + ) + + assert response.status_code == 201 + data = response.get_json() + assert data["parent_epic_id"] == parent_epic.id + + def test_create_epic_with_completed_list( + self, client, db_session, auth_headers, test_board, test_list + ): + """Test creating epic with completed list""" + epic_data = { + "name": "Epic with Completed List", + "completed_list_id": test_list.id, + } + + response = client.post( + f"/api/boards/{test_board.id}/epics", + headers=auth_headers, + json=epic_data, + ) + + assert response.status_code == 201 + data = response.get_json() + assert data["completed_list_id"] == test_list.id + + def test_create_epic_board_not_found(self, client, db_session, auth_headers): + """Test creating epic for non-existent board""" + epic_data = {"name": "Test Epic"} + + response = client.post( + "/api/boards/99999/epics", + headers=auth_headers, + json=epic_data, + ) + + assert response.status_code == 404 + data = response.get_json() + assert "not found" in data["error"].lower() + + def test_create_epic_unauthorized(self, client, db_session, test_board): + """Test creating epic without authentication""" + epic_data = {"name": "Test Epic"} + + response = client.post( + f"/api/boards/{test_board.id}/epics", + json=epic_data, + ) + + assert response.status_code == 401 + + def test_create_epic_missing_name( + self, client, db_session, auth_headers, test_board + ): + """Test creating epic without name""" + epic_data = {"description": "No name provided"} + + response = client.post( + f"/api/boards/{test_board.id}/epics", + headers=auth_headers, + json=epic_data, + ) + + assert response.status_code == 400 + + def test_create_epic_name_too_long( + self, client, db_session, auth_headers, test_board + ): + """Test creating epic with name exceeding max length""" + epic_data = {"name": "A" * 201} + + response = client.post( + f"/api/boards/{test_board.id}/epics", + headers=auth_headers, + json=epic_data, + ) + + assert response.status_code == 400 + + def test_create_epic_invalid_depth_limit( + self, client, db_session, auth_headers, test_board + ): + """Test creating epic with invalid depth limit""" + epic_data = {"name": "Test Epic", "depth_limit": 15} + + response = client.post( + f"/api/boards/{test_board.id}/epics", + headers=auth_headers, + json=epic_data, + ) + + assert response.status_code == 400 + + def test_create_epic_invalid_color( + self, client, db_session, auth_headers, test_board + ): + """Test creating epic with invalid color code""" + epic_data = {"name": "Test Epic", "color": "invalid-color"} + + response = client.post( + f"/api/boards/{test_board.id}/epics", + headers=auth_headers, + json=epic_data, + ) + + assert response.status_code == 400 + + # ========== GET /epics/ ========== + + def test_get_epic_success( + self, client, db_session, auth_headers, test_board, test_card + ): + """Test getting a specific epic with details""" + # Create epic and link card + epic = Epic(name="Test Epic", board_id=test_board.id, color="#3b82f6") + db_session.add(epic) + db_session.commit() + + test_card.epic_id = epic.id + db_session.commit() + + response = client.get(f"/api/epics/{epic.id}", headers=auth_headers) + + assert response.status_code == 200 + data = response.get_json() + assert data["id"] == epic.id + assert data["name"] == "Test Epic" + assert "cards" in data + assert len(data["cards"]) == 1 + assert data["cards"][0]["id"] == test_card.id + + def test_get_epic_with_no_cards(self, client, db_session, auth_headers, test_board): + """Test getting epic with no cards""" + epic = Epic(name="Empty Epic", board_id=test_board.id) + db_session.add(epic) + db_session.commit() + + response = client.get(f"/api/epics/{epic.id}", headers=auth_headers) + + assert response.status_code == 200 + data = response.get_json() + assert data["id"] == epic.id + assert "cards" in data + assert len(data["cards"]) == 0 + + def test_get_epic_not_found(self, client, db_session, auth_headers): + """Test getting non-existent epic""" + response = client.get("/api/epics/99999", headers=auth_headers) + + assert response.status_code == 404 + data = response.get_json() + assert "not found" in data["error"].lower() + + def test_get_epic_unauthorized(self, client, db_session, test_board): + """Test getting epic without authentication""" + epic = Epic(name="Test Epic", board_id=test_board.id) + db_session.add(epic) + db_session.commit() + + response = client.get(f"/api/epics/{epic.id}") + + assert response.status_code == 401 + + # ========== PUT /epics/ ========== + + def test_update_epic_success(self, client, db_session, auth_headers, test_board): + """Test updating an epic successfully""" + epic = Epic( + name="Original Name", + board_id=test_board.id, + color="#3b82f6", + closed=False, + ) + db_session.add(epic) + db_session.commit() + + update_data = { + "name": "Updated Name", + "description": "Updated description", + "color": "#ef4444", + "closed": True, + "pos": 5.0, + "depth_limit": 3, + } + + response = client.put( + f"/api/epics/{epic.id}", + headers=auth_headers, + json=update_data, + ) + + assert response.status_code == 200 + data = response.get_json() + assert data["name"] == "Updated Name" + assert data["description"] == "Updated description" + assert data["color"] == "#ef4444" + assert data["closed"] is True + assert data["pos"] == 5.0 + assert data["depth_limit"] == 3 + + def test_update_epic_partial(self, client, db_session, auth_headers, test_board): + """Test updating epic with partial data""" + epic = Epic(name="Original Name", board_id=test_board.id) + db_session.add(epic) + db_session.commit() + + update_data = {"name": "Updated Name"} + + response = client.put( + f"/api/epics/{epic.id}", + headers=auth_headers, + json=update_data, + ) + + assert response.status_code == 200 + data = response.get_json() + assert data["name"] == "Updated Name" + # Other fields should remain unchanged + assert data["description"] is None + + def test_update_epic_content(self, client, db_session, auth_headers, test_board): + """Test updating epic content""" + epic = Epic(name="Test Epic", board_id=test_board.id) + db_session.add(epic) + db_session.commit() + + new_content = [{"type": "heading", "children": [{"text": "Updated Content"}]}] + + response = client.put( + f"/api/epics/{epic.id}", + headers=auth_headers, + json={"content": new_content}, + ) + + assert response.status_code == 200 + data = response.get_json() + assert data["content"] == new_content + + def test_update_epic_not_found(self, client, db_session, auth_headers): + """Test updating non-existent epic""" + response = client.put( + "/api/epics/99999", + headers=auth_headers, + json={"name": "Updated"}, + ) + + assert response.status_code == 404 + + def test_update_epic_unauthorized(self, client, db_session, test_board): + """Test updating epic without authentication""" + epic = Epic(name="Test Epic", board_id=test_board.id) + db_session.add(epic) + db_session.commit() + + response = client.put(f"/api/epics/{epic.id}", json={"name": "Updated"}) + + assert response.status_code == 401 + + def test_update_epic_invalid_name( + self, client, db_session, auth_headers, test_board + ): + """Test updating epic with invalid name""" + epic = Epic(name="Test Epic", board_id=test_board.id) + db_session.add(epic) + db_session.commit() + + response = client.put( + f"/api/epics/{epic.id}", + headers=auth_headers, + json={"name": ""}, + ) + + assert response.status_code == 400 + + # ========== DELETE /epics/ ========== + + def test_delete_epic_success(self, client, db_session, auth_headers, test_board): + """Test deleting an epic successfully""" + epic = Epic(name="To Delete", board_id=test_board.id) + db_session.add(epic) + db_session.commit() + epic_id = epic.id + + response = client.delete(f"/api/epics/{epic.id}", headers=auth_headers) + + assert response.status_code == 200 + data = response.get_json() + assert "message" in data + + # Verify epic is deleted + deleted_epic = db.session.get(Epic, epic_id) + assert deleted_epic is None + + def test_delete_epic_with_cards( + self, client, db_session, auth_headers, test_board, test_card + ): + """Test deleting epic unlinks cards""" + epic = Epic(name="Epic with Cards", board_id=test_board.id) + db_session.add(epic) + db_session.commit() + + # Link card to epic + test_card.epic_id = epic.id + db_session.commit() + + response = client.delete(f"/api/epics/{epic.id}", headers=auth_headers) + + assert response.status_code == 200 + + # Verify card is unlinked + db.session.refresh(test_card) + assert test_card.epic_id is None + + def test_delete_epic_not_found(self, client, db_session, auth_headers): + """Test deleting non-existent epic""" + response = client.delete("/api/epics/99999", headers=auth_headers) + + assert response.status_code == 404 + + def test_delete_epic_unauthorized(self, client, db_session, test_board): + """Test deleting epic without authentication""" + epic = Epic(name="Test Epic", board_id=test_board.id) + db_session.add(epic) + db_session.commit() + + response = client.delete(f"/api/epics/{epic.id}") + + assert response.status_code == 401 + + # ========== POST /cards//epics ========== + + def test_add_epic_to_card_success( + self, client, db_session, auth_headers, test_board, test_card + ): + """Test linking an epic to a card successfully""" + epic = Epic(name="Test Epic", board_id=test_board.id) + db_session.add(epic) + db_session.commit() + + response = client.post( + f"/api/cards/{test_card.id}/epics", + headers=auth_headers, + json={"epic_id": epic.id}, + ) + + assert response.status_code == 200 + data = response.get_json() + assert data["epic_id"] == epic.id + + # Verify card is linked + db.session.refresh(test_card) + assert test_card.epic_id == epic.id + + def test_add_epic_to_card_already_linked( + self, client, db_session, auth_headers, test_board, test_card + ): + """Test linking epic to card that already has an epic""" + epic = Epic(name="Test Epic", board_id=test_board.id) + db_session.add(epic) + db_session.commit() + + test_card.epic_id = epic.id + db_session.commit() + + # Try to link to same epic again (should just update) + response = client.post( + f"/api/cards/{test_card.id}/epics", + headers=auth_headers, + json={"epic_id": epic.id}, + ) + + assert response.status_code == 200 + + def test_add_epic_to_card_replacement( + self, client, db_session, auth_headers, test_board, test_card + ): + """Test replacing epic on card""" + epic1 = Epic(name="Epic 1", board_id=test_board.id) + epic2 = Epic(name="Epic 2", board_id=test_board.id) + db_session.add(epic1) + db_session.add(epic2) + db_session.commit() + + test_card.epic_id = epic1.id + db_session.commit() + + # Replace with epic2 + response = client.post( + f"/api/cards/{test_card.id}/epics", + headers=auth_headers, + json={"epic_id": epic2.id}, + ) + + assert response.status_code == 200 + + # Verify card is linked to epic2 + db.session.refresh(test_card) + assert test_card.epic_id == epic2.id + + def test_add_epic_to_card_not_found(self, client, db_session, auth_headers): + """Test linking epic to non-existent card""" + response = client.post( + "/api/cards/99999/epics", + headers=auth_headers, + json={"epic_id": 1}, + ) + + assert response.status_code == 404 + + def test_add_epic_to_card_epic_not_found( + self, client, db_session, auth_headers, test_card + ): + """Test linking non-existent epic to card""" + response = client.post( + f"/api/cards/{test_card.id}/epics", + headers=auth_headers, + json={"epic_id": 99999}, + ) + + assert response.status_code == 404 + + def test_add_epic_to_card_missing_epic_id( + self, client, db_session, auth_headers, test_card + ): + """Test linking epic to card without epic_id""" + response = client.post( + f"/api/cards/{test_card.id}/epics", + headers=auth_headers, + json={}, + ) + + assert response.status_code == 400 + data = response.get_json() + assert "required" in data["error"].lower() + + def test_add_epic_to_card_unauthorized(self, client, db_session, test_card): + """Test linking epic to card without authentication""" + response = client.post( + f"/api/cards/{test_card.id}/epics", + json={"epic_id": 1}, + ) + + assert response.status_code == 401 + + # ========== DELETE /cards//epics/ ========== + + def test_remove_epic_from_card_success( + self, client, db_session, auth_headers, test_board, test_card + ): + """Test unlinking an epic from a card successfully""" + epic = Epic(name="Test Epic", board_id=test_board.id) + db_session.add(epic) + db_session.commit() + + test_card.epic_id = epic.id + db_session.commit() + + response = client.delete( + f"/api/cards/{test_card.id}/epics/{epic.id}", + headers=auth_headers, + ) + + assert response.status_code == 200 + data = response.get_json() + assert "message" in data + + # Verify card is unlinked + db.session.refresh(test_card) + assert test_card.epic_id is None + + def test_remove_epic_from_card_card_not_found( + self, client, db_session, auth_headers + ): + """Test unlinking epic from non-existent card""" + response = client.delete( + "/api/cards/99999/epics/1", + headers=auth_headers, + ) + + assert response.status_code == 404 + + def test_remove_epic_from_card_wrong_epic( + self, client, db_session, auth_headers, test_board, test_card + ): + """Test unlinking wrong epic from card""" + epic1 = Epic(name="Epic 1", board_id=test_board.id) + epic2 = Epic(name="Epic 2", board_id=test_board.id) + db_session.add(epic1) + db_session.add(epic2) + db_session.commit() + + test_card.epic_id = epic1.id + db_session.commit() + + # Try to unlink epic2 (but card has epic1) + response = client.delete( + f"/api/cards/{test_card.id}/epics/{epic2.id}", + headers=auth_headers, + ) + + assert response.status_code == 400 + data = response.get_json() + assert "not linked" in data["error"].lower() + + def test_remove_epic_from_card_no_epic( + self, client, db_session, auth_headers, test_board, test_card + ): + """Test unlinking epic from card that has no epic""" + epic = Epic(name="Test Epic", board_id=test_board.id) + db_session.add(epic) + db_session.commit() + + # Card has no epic linked + assert test_card.epic_id is None + + response = client.delete( + f"/api/cards/{test_card.id}/epics/{epic.id}", + headers=auth_headers, + ) + + assert response.status_code == 400 + data = response.get_json() + assert "not linked" in data["error"].lower() + + def test_remove_epic_from_card_unauthorized( + self, client, db_session, test_board, test_card + ): + """Test unlinking epic from card without authentication""" + epic = Epic(name="Test Epic", board_id=test_board.id) + db_session.add(epic) + db_session.commit() + + test_card.epic_id = epic.id + db_session.commit() + + response = client.delete(f"/api/cards/{test_card.id}/epics/{epic.id}") + + assert response.status_code == 401 + + # ========== Epic Hierarchy Tests ========== + + def test_epic_hierarchy(self, client, db_session, auth_headers, test_board): + """Test creating nested epic hierarchy""" + # Create parent epic + parent_epic = Epic(name="Parent Epic", board_id=test_board.id) + db_session.add(parent_epic) + db_session.commit() + + # Create child epic + child_epic = Epic( + name="Child Epic", + board_id=test_board.id, + parent_epic_id=parent_epic.id, + ) + db_session.add(child_epic) + db_session.commit() + + # Verify hierarchy + response = client.get( + f"/api/epics/{parent_epic.id}", + headers=auth_headers, + ) + + assert response.status_code == 200 + data = response.get_json() + assert data["parent_epic_id"] is None + + response = client.get( + f"/api/epics/{child_epic.id}", + headers=auth_headers, + ) + + assert response.status_code == 200 + data = response.get_json() + assert data["parent_epic_id"] == parent_epic.id diff --git a/backend/tests/routes/test_wikis.py b/backend/tests/routes/test_wikis.py new file mode 100644 index 0000000..3603d2b --- /dev/null +++ b/backend/tests/routes/test_wikis.py @@ -0,0 +1,882 @@ +"""Integration tests for Wiki API routes""" +import pytest + +from app import db +from app.models import Epic, Wiki, wiki_entity_links + + +@pytest.mark.integration +class TestWikiRoutes: + """Test Wiki API routes""" + + # ========== GET /boards//wikis ========== + + def test_get_board_wikis_success( + self, client, db_session, regular_user, auth_headers, test_board + ): + """Test getting all wikis for a board""" + # Create wikis for board + wiki1 = Wiki( + name="Getting Started", + board_id=test_board.id, + slug="getting-started", + content=[ + {"type": "paragraph", "children": [{"text": "Getting started content"}]} + ], + created_by=regular_user.id, + ) + wiki2 = Wiki( + name="API Documentation", + board_id=test_board.id, + slug="api-documentation", + content=[{"type": "paragraph", "children": [{"text": "API docs content"}]}], + created_by=regular_user.id, + ) + db_session.add(wiki1) + db_session.add(wiki2) + db_session.commit() + + response = client.get( + f"/api/boards/{test_board.id}/wikis", headers=auth_headers + ) + + assert response.status_code == 200 + data = response.get_json() + assert len(data) == 2 + assert data[0]["name"] == "Getting Started" + assert data[1]["name"] == "API Documentation" + + def test_get_board_wikis_empty(self, client, db_session, auth_headers, test_board): + """Test getting wikis when none exist for board""" + response = client.get( + f"/api/boards/{test_board.id}/wikis", headers=auth_headers + ) + + assert response.status_code == 200 + data = response.get_json() + assert len(data) == 0 + + def test_get_board_wikis_board_not_found(self, client, db_session, auth_headers): + """Test getting wikis for non-existent board""" + response = client.get("/api/boards/99999/wikis", headers=auth_headers) + + assert response.status_code == 404 + data = response.get_json() + assert "not found" in data["error"].lower() + + def test_get_board_wikis_unauthorized(self, client, db_session, test_board): + """Test getting wikis without authentication""" + response = client.get(f"/api/boards/{test_board.id}/wikis") + + assert response.status_code == 401 + + # ========== POST /boards//wikis ========== + + def test_create_wiki_success( + self, client, db_session, regular_user, auth_headers, test_board + ): + """Test creating a new wiki successfully""" + wiki_data = { + "name": "User Guide", + "slug": "user-guide", + "content": [{"type": "paragraph", "children": [{"text": "Welcome..."}]}], + "summary": "A comprehensive user guide", + "category": "Documentation", + "tags": ["getting-started", "tutorial"], + } + + response = client.post( + f"/api/boards/{test_board.id}/wikis", + headers=auth_headers, + json=wiki_data, + ) + + assert response.status_code == 201 + data = response.get_json() + assert data["name"] == "User Guide" + assert data["slug"] == "user-guide" + assert data["summary"] == "A comprehensive user guide" + assert data["category"] == "Documentation" + assert data["tags"] == ["getting-started", "tutorial"] + assert data["created_by"] == regular_user.id + assert data["updated_by"] == regular_user.id + assert "id" in data + + def test_create_wiki_minimal_data( + self, client, db_session, auth_headers, test_board + ): + """Test creating wiki with only required fields""" + wiki_data = { + "name": "Simple Wiki", + "content": [{"type": "paragraph", "children": [{"text": "Content"}]}], + } + + response = client.post( + f"/api/boards/{test_board.id}/wikis", + headers=auth_headers, + json=wiki_data, + ) + + assert response.status_code == 201 + data = response.get_json() + assert data["name"] == "Simple Wiki" + assert data["slug"] == "simple-wiki" # Auto-generated + assert data["summary"] is None + assert data["category"] is None + assert data["tags"] is None + + def test_create_wiki_auto_generate_slug( + self, client, db_session, auth_headers, test_board + ): + """Test creating wiki with auto-generated slug""" + wiki_data = { + "name": "API Reference Guide", + "content": [{"type": "paragraph", "children": [{"text": "Content"}]}], + } + + response = client.post( + f"/api/boards/{test_board.id}/wikis", + headers=auth_headers, + json=wiki_data, + ) + + assert response.status_code == 201 + data = response.get_json() + assert data["slug"] == "api-reference-guide" + + def test_create_wiki_slug_collision( + self, client, db_session, auth_headers, test_board, regular_user + ): + """Test creating wiki with colliding slug""" + # Create first wiki + wiki1 = Wiki( + name="Test Wiki", + board_id=test_board.id, + slug="test-wiki", + content=[{"type": "paragraph", "children": [{"text": "Content"}]}], + created_by=regular_user.id, + ) + db_session.add(wiki1) + db_session.commit() + + # Create second wiki with same name (should auto-increment slug) + wiki_data = { + "name": "Test Wiki", + "content": [{"type": "paragraph", "children": [{"text": "Content"}]}], + } + + response = client.post( + f"/api/boards/{test_board.id}/wikis", + headers=auth_headers, + json=wiki_data, + ) + + assert response.status_code == 201 + data = response.get_json() + assert data["slug"] == "test-wiki-1" + + def test_create_wiki_board_not_found(self, client, db_session, auth_headers): + """Test creating wiki for non-existent board""" + wiki_data = { + "name": "Test Wiki", + "content": [{"type": "paragraph", "children": [{"text": "Content"}]}], + } + + response = client.post( + "/api/boards/99999/wikis", + headers=auth_headers, + json=wiki_data, + ) + + assert response.status_code == 404 + data = response.get_json() + assert "not found" in data["error"].lower() + + def test_create_wiki_unauthorized(self, client, db_session, test_board): + """Test creating wiki without authentication""" + wiki_data = { + "name": "Test Wiki", + "content": [{"type": "paragraph", "children": [{"text": "Content"}]}], + } + + response = client.post( + f"/api/boards/{test_board.id}/wikis", + json=wiki_data, + ) + + assert response.status_code == 401 + + def test_create_wiki_missing_name( + self, client, db_session, auth_headers, test_board + ): + """Test creating wiki without name""" + wiki_data = {"summary": "No name provided"} + + response = client.post( + f"/api/boards/{test_board.id}/wikis", + headers=auth_headers, + json=wiki_data, + ) + + assert response.status_code == 400 + + def test_create_wiki_name_too_long( + self, client, db_session, auth_headers, test_board + ): + """Test creating wiki with name exceeding max length""" + wiki_data = { + "name": "A" * 201, + "content": [{"type": "paragraph", "children": [{"text": "Content"}]}], + } + + response = client.post( + f"/api/boards/{test_board.id}/wikis", + headers=auth_headers, + json=wiki_data, + ) + + assert response.status_code == 400 + + def test_create_wiki_invalid_slug( + self, client, db_session, auth_headers, test_board + ): + """Test creating wiki with invalid slug format""" + wiki_data = { + "name": "Test Wiki", + "slug": "invalid slug!", + "content": [{"type": "paragraph", "children": [{"text": "Content"}]}], + } + + response = client.post( + f"/api/boards/{test_board.id}/wikis", + headers=auth_headers, + json=wiki_data, + ) + + assert response.status_code == 400 + + # ========== GET /wikis/ ========== + + def test_get_wiki_success( + self, client, db_session, auth_headers, test_board, regular_user + ): + """Test getting a specific wiki with details""" + wiki = Wiki( + name="Test Wiki", + board_id=test_board.id, + slug="test-wiki", + content=[{"type": "paragraph", "children": [{"text": "Test content"}]}], + created_by=regular_user.id, + ) + db_session.add(wiki) + db_session.commit() + + response = client.get(f"/api/wikis/{wiki.id}", headers=auth_headers) + + assert response.status_code == 200 + data = response.get_json() + assert data["id"] == wiki.id + assert data["name"] == "Test Wiki" + assert data["slug"] == "test-wiki" + assert "linked_cards" in data + assert "linked_epics" in data + + def test_get_wiki_with_linked_entities( + self, client, db_session, auth_headers, test_board, regular_user, test_card + ): + """Test getting wiki with linked cards and epics""" + wiki = Wiki( + name="Test Wiki", + board_id=test_board.id, + slug="test-wiki", + content=[{"type": "paragraph", "children": [{"text": "Test content"}]}], + created_by=regular_user.id, + ) + db_session.add(wiki) + db_session.commit() + + # Link card to wiki + db.session.execute( + wiki_entity_links.insert().values( + wiki_id=wiki.id, entity_type="card", entity_id=test_card.id + ) + ) + db_session.commit() + + response = client.get(f"/api/wikis/{wiki.id}", headers=auth_headers) + + assert response.status_code == 200 + data = response.get_json() + assert "linked_cards" in data + assert "linked_epics" in data + assert len(data["linked_cards"]) == 1 + assert data["linked_cards"][0]["id"] == test_card.id + + def test_get_wiki_not_found(self, client, db_session, auth_headers): + """Test getting non-existent wiki""" + response = client.get("/api/wikis/99999", headers=auth_headers) + + assert response.status_code == 404 + data = response.get_json() + assert "not found" in data["error"].lower() + + def test_get_wiki_unauthorized(self, client, db_session, test_board, regular_user): + """Test getting wiki without authentication""" + wiki = Wiki( + name="Test Wiki", + board_id=test_board.id, + slug="test-wiki", + content=[{"type": "paragraph", "children": [{"text": "Test content"}]}], + created_by=regular_user.id, + ) + db_session.add(wiki) + db_session.commit() + + response = client.get(f"/api/wikis/{wiki.id}") + + assert response.status_code == 401 + + # ========== PUT /wikis/ ========== + + def test_update_wiki_success( + self, client, db_session, auth_headers, test_board, regular_user + ): + """Test updating a wiki successfully""" + wiki = Wiki( + name="Original Name", + board_id=test_board.id, + slug="original-slug", + content=[{"type": "paragraph", "children": [{"text": "Original content"}]}], + created_by=regular_user.id, + ) + db_session.add(wiki) + db_session.commit() + + update_data = { + "name": "Updated Name", + "slug": "updated-slug", + "summary": "Updated summary", + "category": "Documentation", + "tags": ["updated", "tag"], + } + + response = client.put( + f"/api/wikis/{wiki.id}", + headers=auth_headers, + json=update_data, + ) + + assert response.status_code == 200 + data = response.get_json() + assert data["name"] == "Updated Name" + assert data["slug"] == "updated-slug" + assert data["summary"] == "Updated summary" + assert data["category"] == "Documentation" + assert data["tags"] == ["updated", "tag"] + + def test_update_wiki_partial( + self, client, db_session, auth_headers, test_board, regular_user + ): + """Test updating wiki with partial data""" + wiki = Wiki( + name="Original Name", + board_id=test_board.id, + content=[{"type": "paragraph", "children": [{"text": "Original content"}]}], + created_by=regular_user.id, + ) + db_session.add(wiki) + db_session.commit() + + update_data = {"name": "Updated Name"} + + response = client.put( + f"/api/wikis/{wiki.id}", + headers=auth_headers, + json=update_data, + ) + + assert response.status_code == 200 + data = response.get_json() + assert data["name"] == "Updated Name" + # Other fields should remain unchanged + + def test_update_wiki_content( + self, client, db_session, auth_headers, test_board, regular_user + ): + """Test updating wiki content""" + wiki = Wiki( + name="Test Wiki", + board_id=test_board.id, + content=[{"type": "paragraph", "children": [{"text": "Original content"}]}], + created_by=regular_user.id, + ) + db_session.add(wiki) + db_session.commit() + + new_content = [{"type": "heading", "children": [{"text": "Updated Content"}]}] + + response = client.put( + f"/api/wikis/{wiki.id}", + headers=auth_headers, + json={"content": new_content}, + ) + + assert response.status_code == 200 + data = response.get_json() + assert data["content"] == new_content + + def test_update_wiki_not_found(self, client, db_session, auth_headers): + """Test updating non-existent wiki""" + response = client.put( + "/api/wikis/99999", + headers=auth_headers, + json={"name": "Updated"}, + ) + + assert response.status_code == 404 + + def test_update_wiki_unauthorized( + self, client, db_session, test_board, regular_user + ): + """Test updating wiki without authentication""" + wiki = Wiki( + name="Test Wiki", + board_id=test_board.id, + content=[{"type": "paragraph", "children": [{"text": "Test content"}]}], + created_by=regular_user.id, + ) + db_session.add(wiki) + db_session.commit() + + response = client.put(f"/api/wikis/{wiki.id}", json={"name": "Updated"}) + + assert response.status_code == 401 + + def test_update_wiki_invalid_name( + self, client, db_session, auth_headers, test_board, regular_user + ): + """Test updating wiki with invalid name""" + wiki = Wiki( + name="Test Wiki", + board_id=test_board.id, + content=[{"type": "paragraph", "children": [{"text": "Test content"}]}], + created_by=regular_user.id, + ) + db_session.add(wiki) + db_session.commit() + + response = client.put( + f"/api/wikis/{wiki.id}", + headers=auth_headers, + json={"name": ""}, + ) + + assert response.status_code == 400 + + def test_update_wiki_invalid_slug( + self, client, db_session, auth_headers, test_board, regular_user + ): + """Test updating wiki with invalid slug""" + wiki = Wiki( + name="Test Wiki", + board_id=test_board.id, + content=[{"type": "paragraph", "children": [{"text": "Test content"}]}], + created_by=regular_user.id, + ) + db_session.add(wiki) + db_session.commit() + + response = client.put( + f"/api/wikis/{wiki.id}", + headers=auth_headers, + json={"slug": "invalid slug!"}, + ) + + assert response.status_code == 400 + + # ========== DELETE /wikis/ ========== + + def test_delete_wiki_success( + self, client, db_session, auth_headers, test_board, regular_user + ): + """Test deleting a wiki successfully""" + wiki = Wiki( + name="To Delete", + board_id=test_board.id, + content=[{"type": "paragraph", "children": [{"text": "Test content"}]}], + created_by=regular_user.id, + ) + db_session.add(wiki) + db_session.commit() + wiki_id = wiki.id + + response = client.delete(f"/api/wikis/{wiki.id}", headers=auth_headers) + + assert response.status_code == 200 + data = response.get_json() + assert "message" in data + + # Verify wiki is deleted + deleted_wiki = db.session.get(Wiki, wiki_id) + assert deleted_wiki is None + + def test_delete_wiki_with_links( + self, client, db_session, auth_headers, test_board, regular_user, test_card + ): + """Test deleting wiki removes entity links""" + wiki = Wiki( + name="Wiki with Links", + board_id=test_board.id, + content=[{"type": "paragraph", "children": [{"text": "Test content"}]}], + created_by=regular_user.id, + ) + db_session.add(wiki) + db_session.commit() + + # Link card to wiki + db.session.execute( + wiki_entity_links.insert().values( + wiki_id=wiki.id, entity_type="card", entity_id=test_card.id + ) + ) + db_session.commit() + + response = client.delete(f"/api/wikis/{wiki.id}", headers=auth_headers) + + assert response.status_code == 200 + + # Verify links are deleted (cascade) + from sqlalchemy import select + + links = db.session.scalars( + select(wiki_entity_links).where(wiki_entity_links.c.wiki_id == wiki.id) + ).all() + assert len(links) == 0 + + def test_delete_wiki_not_found(self, client, db_session, auth_headers): + """Test deleting non-existent wiki""" + response = client.delete("/api/wikis/99999", headers=auth_headers) + + assert response.status_code == 404 + + def test_delete_wiki_unauthorized( + self, client, db_session, test_board, regular_user + ): + """Test deleting wiki without authentication""" + wiki = Wiki( + name="Test Wiki", + board_id=test_board.id, + content=[{"type": "paragraph", "children": [{"text": "Test content"}]}], + created_by=regular_user.id, + ) + db_session.add(wiki) + db_session.commit() + + response = client.delete(f"/api/wikis/{wiki.id}") + + assert response.status_code == 401 + + # ========== POST /wikis//links ========== + + def test_create_wiki_link_card( + self, client, db_session, auth_headers, test_board, regular_user, test_card + ): + """Test linking wiki to card successfully""" + wiki = Wiki( + name="Test Wiki", + board_id=test_board.id, + content=[{"type": "paragraph", "children": [{"text": "Test content"}]}], + created_by=regular_user.id, + ) + db_session.add(wiki) + db_session.commit() + + response = client.post( + f"/api/wikis/{wiki.id}/links", + headers=auth_headers, + json={"entity_type": "card", "entity_id": test_card.id}, + ) + + assert response.status_code == 201 + data = response.get_json() + assert "message" in data + + # Verify link is created + from sqlalchemy import select + + link = db.session.scalars( + select(wiki_entity_links).where( + wiki_entity_links.c.wiki_id == wiki.id, + wiki_entity_links.c.entity_type == "card", + wiki_entity_links.c.entity_id == test_card.id, + ) + ).first() + assert link is not None + + def test_create_wiki_link_epic( + self, + client, + db_session, + auth_headers, + test_board, + regular_user, + test_list, + ): + """Test linking wiki to epic successfully""" + wiki = Wiki( + name="Test Wiki", + board_id=test_board.id, + content=[{"type": "paragraph", "children": [{"text": "Test content"}]}], + created_by=regular_user.id, + ) + epic = Epic(name="Test Epic", board_id=test_board.id) + db_session.add(wiki) + db_session.add(epic) + db_session.commit() + + response = client.post( + f"/api/wikis/{wiki.id}/links", + headers=auth_headers, + json={"entity_type": "epic", "entity_id": epic.id}, + ) + + assert response.status_code == 201 + + # Verify link is created + from sqlalchemy import select + + link = db.session.scalars( + select(wiki_entity_links).where( + wiki_entity_links.c.wiki_id == wiki.id, + wiki_entity_links.c.entity_type == "epic", + wiki_entity_links.c.entity_id == epic.id, + ) + ).first() + assert link is not None + + def test_create_wiki_link_list( + self, + client, + db_session, + auth_headers, + test_board, + regular_user, + test_list, + ): + """Test linking wiki to list successfully""" + wiki = Wiki( + name="Test Wiki", + board_id=test_board.id, + content=[{"type": "paragraph", "children": [{"text": "Test content"}]}], + created_by=regular_user.id, + ) + db_session.add(wiki) + db_session.commit() + + response = client.post( + f"/api/wikis/{wiki.id}/links", + headers=auth_headers, + json={"entity_type": "list", "entity_id": test_list.id}, + ) + + assert response.status_code == 201 + + def test_create_wiki_link_board( + self, client, db_session, auth_headers, test_board, regular_user + ): + """Test linking wiki to board successfully""" + wiki = Wiki( + name="Test Wiki", + board_id=test_board.id, + content=[{"type": "paragraph", "children": [{"text": "Test content"}]}], + created_by=regular_user.id, + ) + db_session.add(wiki) + db_session.commit() + + response = client.post( + f"/api/wikis/{wiki.id}/links", + headers=auth_headers, + json={"entity_type": "board", "entity_id": test_board.id}, + ) + + assert response.status_code == 201 + + def test_create_wiki_link_already_exists( + self, client, db_session, auth_headers, test_board, regular_user, test_card + ): + """Test creating duplicate link""" + wiki = Wiki( + name="Test Wiki", + board_id=test_board.id, + content=[{"type": "paragraph", "children": [{"text": "Test content"}]}], + created_by=regular_user.id, + ) + db_session.add(wiki) + db_session.commit() + + # Create first link + db.session.execute( + wiki_entity_links.insert().values( + wiki_id=wiki.id, entity_type="card", entity_id=test_card.id + ) + ) + db_session.commit() + + # Try to create duplicate link + response = client.post( + f"/api/wikis/{wiki.id}/links", + headers=auth_headers, + json={"entity_type": "card", "entity_id": test_card.id}, + ) + + assert response.status_code == 400 + data = response.get_json() + assert "already exists" in data["error"].lower() + + def test_create_wiki_link_wiki_not_found(self, client, db_session, auth_headers): + """Test linking non-existent wiki""" + response = client.post( + "/api/wikis/99999/links", + headers=auth_headers, + json={"entity_type": "card", "entity_id": 1}, + ) + + assert response.status_code == 404 + + def test_create_wiki_link_entity_not_found( + self, client, db_session, auth_headers, test_board, regular_user + ): + """Test linking to non-existent entity""" + wiki = Wiki( + name="Test Wiki", + board_id=test_board.id, + content=[{"type": "paragraph", "children": [{"text": "Test content"}]}], + created_by=regular_user.id, + ) + db_session.add(wiki) + db_session.commit() + + response = client.post( + f"/api/wikis/{wiki.id}/links", + headers=auth_headers, + json={"entity_type": "card", "entity_id": 99999}, + ) + + assert response.status_code == 404 + + def test_create_wiki_link_unauthorized( + self, client, db_session, test_board, regular_user + ): + """Test creating link without authentication""" + wiki = Wiki( + name="Test Wiki", + board_id=test_board.id, + content=[{"type": "paragraph", "children": [{"text": "Test content"}]}], + created_by=regular_user.id, + ) + db_session.add(wiki) + db_session.commit() + + response = client.post( + f"/api/wikis/{wiki.id}/links", + json={"entity_type": "card", "entity_id": 1}, + ) + + assert response.status_code == 401 + + # ========== DELETE /wikis//links ========== + + def test_delete_wiki_link_success( + self, client, db_session, auth_headers, test_board, regular_user, test_card + ): + """Test deleting a wiki link successfully""" + wiki = Wiki( + name="Test Wiki", + board_id=test_board.id, + content=[{"type": "paragraph", "children": [{"text": "Test content"}]}], + created_by=regular_user.id, + ) + db_session.add(wiki) + db_session.commit() + + # Create link + db.session.execute( + wiki_entity_links.insert().values( + wiki_id=wiki.id, entity_type="card", entity_id=test_card.id + ) + ) + db_session.commit() + + response = client.delete( + f"/api/wikis/{wiki.id}/links", + headers=auth_headers, + json={"entity_type": "card", "entity_id": test_card.id}, + ) + + assert response.status_code == 200 + data = response.get_json() + assert "message" in data + + # Verify link is deleted + from sqlalchemy import select + + link = db.session.scalars( + select(wiki_entity_links).where( + wiki_entity_links.c.wiki_id == wiki.id, + wiki_entity_links.c.entity_type == "card", + wiki_entity_links.c.entity_id == test_card.id, + ) + ).first() + assert link is None + + def test_delete_wiki_link_wiki_not_found(self, client, db_session, auth_headers): + """Test deleting link for non-existent wiki""" + response = client.delete( + "/api/wikis/99999/links", + headers=auth_headers, + json={"entity_type": "card", "entity_id": 1}, + ) + + assert response.status_code == 404 + + def test_delete_wiki_link_not_found( + self, client, db_session, auth_headers, test_board, regular_user + ): + """Test deleting non-existent link""" + wiki = Wiki( + name="Test Wiki", + board_id=test_board.id, + content=[{"type": "paragraph", "children": [{"text": "Test content"}]}], + created_by=regular_user.id, + ) + db_session.add(wiki) + db_session.commit() + + response = client.delete( + f"/api/wikis/{wiki.id}/links", + headers=auth_headers, + json={"entity_type": "card", "entity_id": 99999}, + ) + + assert response.status_code == 404 + + def test_delete_wiki_link_unauthorized( + self, client, db_session, test_board, regular_user + ): + """Test deleting link without authentication""" + wiki = Wiki( + name="Test Wiki", + board_id=test_board.id, + content=[{"type": "paragraph", "children": [{"text": "Test content"}]}], + created_by=regular_user.id, + ) + db_session.add(wiki) + db_session.commit() + + response = client.delete( + f"/api/wikis/{wiki.id}/links", + json={"entity_type": "card", "entity_id": 1}, + ) + + assert response.status_code == 401 diff --git a/backend/tests/test_routes.py b/backend/tests/test_routes.py index 57388a5..d9f39b0 100644 --- a/backend/tests/test_routes.py +++ b/backend/tests/test_routes.py @@ -114,232 +114,233 @@ class TestAuthRoutes: assert response.status_code == 401 -class TestProductRoutes: - """Test product routes""" +# class TestProductRoutes: +# """Test product routes""" - @pytest.mark.product - def test_get_products(self, app, client, products): - """Test getting all products""" - from app.models import Product +# @pytest.mark.product +# def test_get_products(self, app, client, products): +# """Test getting all products""" +# from app.models import Product - before_count = Product.query.count() - response = client.get("/api/products") +# before_count = Product.query.count() +# response = client.get("/api/products") - assert response.status_code == 200 - data = response.get_json() - assert len(data) == before_count +# assert response.status_code == 200 +# data = response.get_json() +# assert len(data) == before_count - @pytest.mark.product - def test_get_products_empty(self, client): - """Test getting products when none exist""" - from app.models import Product +# @pytest.mark.product +# def test_get_products_empty(self, client): +# """Test getting products when none exist""" +# from app.models import Product - before_count = Product.query.count() - response = client.get("/api/products") +# before_count = Product.query.count() +# response = client.get("/api/products") - assert response.status_code == 200 - data = response.get_json() - assert len(data) == before_count +# assert response.status_code == 200 +# data = response.get_json() +# assert len(data) == before_count - @pytest.mark.product - def test_get_single_product(self, client, product): - """Test getting a single product""" - response = client.get(f"/api/products/{product.id}") +# @pytest.mark.product +# def test_get_single_product(self, client, product): +# """Test getting a single product""" +# response = client.get(f"/api/products/{product.id}") - assert response.status_code == 200 - data = response.get_json() - assert data["id"] == product.id - assert data["name"] == product.name +# assert response.status_code == 200 +# data = response.get_json() +# assert data["id"] == product.id +# assert data["name"] == product.name - @pytest.mark.product - def test_get_product_not_found(self, client): - """Test getting non-existent product""" - response = client.get("/api/products/999") - assert response.status_code == 404 +# @pytest.mark.product +# def test_get_product_not_found(self, client): +# """Test getting non-existent product""" +# response = client.get("/api/products/999") +# assert response.status_code == 404 - @pytest.mark.product - def test_create_product_admin(self, client, admin_headers): - """Test creating product as admin""" - response = client.post( - "/api/products", - headers=admin_headers, - json={ - "name": "New Product", - "description": "A new product", - "price": 29.99, - "stock": 10, - }, - ) +# @pytest.mark.product +# def test_create_product_admin(self, client, admin_headers): +# """Test creating product as admin""" +# response = client.post( +# "/api/products", +# headers=admin_headers, +# json={ +# "name": "New Product", +# "description": "A new product", +# "price": 29.99, +# "stock": 10, +# }, +# ) - assert response.status_code == 201 - data = response.get_json() - assert data["name"] == "New Product" - assert data["price"] == 29.99 +# assert response.status_code == 201 +# data = response.get_json() +# assert data["name"] == "New Product" +# assert data["price"] == 29.99 - @pytest.mark.product - def test_create_product_regular_user(self, client, auth_headers): - """Test creating product as regular user (should fail)""" - response = client.post( - "/api/products", - headers=auth_headers, - json={"name": "New Product", "price": 29.99}, - ) +# @pytest.mark.product +# def test_create_product_regular_user(self, client, auth_headers): +# """Test creating product as regular user (should fail)""" +# response = client.post( +# "/api/products", +# headers=auth_headers, +# json={"name": "New Product", "price": 29.99}, +# ) - assert response.status_code == 403 - data = response.get_json() - assert "admin" in data["error"].lower() +# assert response.status_code == 403 +# data = response.get_json() +# assert "admin" in data["error"].lower() - @pytest.mark.product - def test_create_product_unauthorized(self, client): - """Test creating product without authentication""" - response = client.post( - "/api/products", json={"name": "New Product", "price": 29.99} - ) +# @pytest.mark.product +# def test_create_product_unauthorized(self, client): +# """Test creating product without authentication""" +# response = client.post( +# "/api/products", json={"name": "New Product", "price": 29.99} +# ) - assert response.status_code == 401 +# assert response.status_code == 401 - @pytest.mark.product - def test_create_product_validation_error(self, client, admin_headers): - """Test creating product with invalid data""" - response = client.post( - "/api/products", - headers=admin_headers, - json={"name": "New Product", "price": -10.99}, - ) +# @pytest.mark.product +# def test_create_product_validation_error(self, client, admin_headers): +# """Test creating product with invalid data""" +# response = client.post( +# "/api/products", +# headers=admin_headers, +# json={"name": "New Product", "price": -10.99}, +# ) - assert response.status_code == 400 - data = response.get_json() - assert "Validation error" in data["error"] +# assert response.status_code == 400 +# data = response.get_json() +# assert "Validation error" in data["error"] - @pytest.mark.product - def test_create_product_missing_required_fields(self, client, admin_headers): - """Test creating product with missing required fields""" - response = client.post( - "/api/products", - headers=admin_headers, - json={"description": "Missing name and price"}, - ) +# @pytest.mark.product +# def test_create_product_missing_required_fields(self, client, admin_headers): +# """Test creating product with missing required fields""" +# response = client.post( +# "/api/products", +# headers=admin_headers, +# json={"description": "Missing name and price"}, +# ) - assert response.status_code == 400 - data = response.get_json() - assert "Validation error" in data["error"] +# assert response.status_code == 400 +# data = response.get_json() +# assert "Validation error" in data["error"] - @pytest.mark.product - def test_create_product_minimal_data(self, client, admin_headers): - """Test creating product with minimal valid data""" - response = client.post( - "/api/products", - headers=admin_headers, - json={"name": "Minimal Product", "price": 19.99}, - ) +# @pytest.mark.product +# def test_create_product_minimal_data(self, client, admin_headers): +# """Test creating product with minimal valid data""" +# response = client.post( +# "/api/products", +# headers=admin_headers, +# json={"name": "Minimal Product", "price": 19.99}, +# ) - assert response.status_code == 201 - data = response.get_json() - assert data["name"] == "Minimal Product" - assert data["stock"] == 0 # Default value +# assert response.status_code == 201 +# data = response.get_json() +# assert data["name"] == "Minimal Product" +# assert data["stock"] == 0 # Default value - @pytest.mark.product - def test_update_product_admin(self, client, admin_headers, product): - """Test updating product as admin""" - response = client.put( - f"/api/products/{product.id}", - headers=admin_headers, - json={"name": "Updated Product", "price": 39.99}, - ) +# @pytest.mark.product +# def test_update_product_admin(self, client, admin_headers, product): +# """Test updating product as admin""" +# response = client.put( +# f"/api/products/{product.id}", +# headers=admin_headers, +# json={"name": "Updated Product", "price": 39.99}, +# ) - assert response.status_code == 200 - data = response.get_json() - assert data["name"] == "Updated Product" - assert data["price"] == 39.99 +# assert response.status_code == 200 +# data = response.get_json() +# assert data["name"] == "Updated Product" +# assert data["price"] == 39.99 - @pytest.mark.product - def test_delete_product_admin(self, client, admin_headers, product): - """Test deleting product as admin""" - response = client.delete(f"/api/products/{product.id}", headers=admin_headers) - assert response.status_code == 200 +# @pytest.mark.product +# def test_delete_product_admin(self, client, admin_headers, product): +# """Test deleting product as admin""" +# response = client.delete(f"/api/products/{product.id}", headers=admin_headers) +# assert response.status_code == 200 - # Verify product is deleted - response = client.get(f"/api/products/{product.id}") - assert response.status_code == 404 +# # Verify product is deleted +# response = client.get(f"/api/products/{product.id}") +# assert response.status_code == 404 -class TestOrderRoutes: - """Test order routes""" +# class TestOrderRoutes: +# """Test order routes""" - @pytest.mark.order - def test_get_orders(self, client, auth_headers, order): - """Test getting orders for current user""" - response = client.get("/api/orders", headers=auth_headers) +# @pytest.mark.order +# def test_get_orders(self, client, auth_headers, order): +# """Test getting orders for current user""" +# response = client.get("/api/orders", headers=auth_headers) - assert response.status_code == 200 - data = response.get_json() - assert len(data) >= 1 +# assert response.status_code == 200 +# data = response.get_json() +# assert len(data) >= 1 - @pytest.mark.order - def test_get_orders_unauthorized(self, client): - """Test getting orders without authentication""" - response = client.get("/api/orders") - assert response.status_code == 401 +# @pytest.mark.order +# def test_get_orders_unauthorized(self, client): +# """Test getting orders without authentication""" +# response = client.get("/api/orders") +# assert response.status_code == 401 - @pytest.mark.order - def test_create_order(self, client, auth_headers, products): - """Test creating an order""" - response = client.post( - "/api/orders", - headers=auth_headers, - json={ - "items": [ - {"product_id": products[0].id, "quantity": 2}, - {"product_id": products[1].id, "quantity": 1}, - ], - "shipping_address": "123 Test St", - }, - ) +# @pytest.mark.order +# def test_create_order(self, client, auth_headers, products): +# """Test creating an order""" +# response = client.post( +# "/api/orders", +# headers=auth_headers, +# json={ +# "items": [ +# {"product_id": products[0].id, "quantity": 2}, +# {"product_id": products[1].id, "quantity": 1}, +# ], +# "shipping_address": "123 Test St", +# }, +# ) - assert response.status_code == 201 - data = response.get_json() - assert "id" in data - assert len(data["items"]) == 2 +# assert response.status_code == 201 +# data = response.get_json() +# assert "id" in data +# assert len(data["items"]) == 2 - @pytest.mark.order - def test_create_order_insufficient_stock( - self, client, auth_headers, db_session, products - ): - """Test creating order with insufficient stock""" - # Set stock to 0 - products[0].stock = 0 - db_session.commit() +# @pytest.mark.order +# def test_create_order_insufficient_stock( +# self, client, auth_headers, db_session, products +# ): +# """Test creating order with insufficient stock""" +# # Set stock to 0 +# products[0].stock = 0 +# db_session.commit() - response = client.post( - "/api/orders", - headers=auth_headers, - json={"items": [{"product_id": products[0].id, "quantity": 2}]}, - ) +# response = client.post( +# "/api/orders", +# headers=auth_headers, +# json={"items": [{"product_id": products[0].id, "quantity": 2}]}, +# ) - assert response.status_code == 400 - data = response.get_json() - assert "insufficient" in data["error"].lower() +# assert response.status_code == 400 +# data = response.get_json() +# assert "insufficient" in data["error"].lower() - @pytest.mark.order - def test_get_single_order(self, client, auth_headers, order): - """Test getting a single order""" - response = client.get(f"/api/orders/{order.id}", headers=auth_headers) +# @pytest.mark.order +# def test_get_single_order(self, client, auth_headers, order): +# """Test getting a single order""" +# response = client.get(f"/api/orders/{order.id}", headers=auth_headers) - print("test_get_single_order", response.get_json()) +# print("test_get_single_order", response.get_json()) - assert response.status_code == 200 - data = response.get_json() - assert data["id"] == order.id +# assert response.status_code == 200 +# data = response.get_json() +# assert data["id"] == order.id - @pytest.mark.order - def test_get_other_users_order(self, client, admin_headers, regular_user, products): - """Test admin accessing another user's order""" - # Create an order for regular_user - client.post( - "/api/auth/login", - json={"email": regular_user.email, "password": "password123"}, - ) +# @pytest.mark.order +# def test_get_other_users_order(self, client, admin_headers, regular_user, +# products): +# """Test admin accessing another user's order""" +# # Create an order for regular_user +# client.post( +# "/api/auth/login", +# json={"email": regular_user.email, "password": "password123"}, +# ) - # Admin should be able to access any order - # This test assumes order exists, adjust as needed - pass +# # Admin should be able to access any order +# # This test assumes order exists, adjust as needed +# pass diff --git a/docs/EPIC_WIKI_IMPLEMENTATION_SUMMARY.md b/docs/EPIC_WIKI_IMPLEMENTATION_SUMMARY.md new file mode 100644 index 0000000..f31d12e --- /dev/null +++ b/docs/EPIC_WIKI_IMPLEMENTATION_SUMMARY.md @@ -0,0 +1,338 @@ +# Epic & Wiki Feature Implementation Summary + +## Overview +This document summarizes the implementation of the Epic and Wiki features for the Kanban application. + +## What Was Implemented + +### Backend (Flask) + +#### 1. Database Models + +**Epic Model** (`backend/app/models/epic.py`) +- Rich text content support (JSON for Slate.js) +- Hierarchical structure (parent_epic_id for nesting) +- Color coding +- Position ordering +- Board-level scoping +- Soft delete support (closed field) +- Depth limit for hierarchy control + +**Wiki Model** (`backend/app/models/wiki.py`) +- Rich text content (JSON for Slate.js) +- URL-friendly slugs +- Summary field +- Categories for organization +- Tags support +- Created by / Updated by tracking +- Board-level scoping + +**Card Model Update** (`backend/app/models/card.py`) +- Added `epic_id` foreign key to link cards to epics + +**Association Table** (`wiki_entity_links`) +- Many-to-many relationship between Wikis and entity types +- Supports linking Wikis to Cards, Epics, and future entity types + +#### 2. Database Migration +- Created migration file: `6fc439155ced_add_epic_and_wiki_models.py` +- Adds all new tables and relationships + +### Frontend (React/TypeScript) + +#### 1. TypeScript Types (`frontend/src/types/epic.ts`) +```typescript +- Epic interface +- Wiki interface +- CreateEpicRequest / UpdateEpicRequest +- CreateWikiRequest / UpdateWikiRequest +- WikiEntityLink interface +``` + +#### 2. Components + +**RichTextEditor** (`frontend/src/components/RichTextEditor.tsx`) +- Slate.js-based rich text editor +- Editable with proper styling +- Dark mode support +- Placeholder support +- Read-only mode option + +**RichTextContent** (`frontend/src/components/RichTextContent.tsx`) +- Read-only renderer for Slate.js content +- Supports paragraphs, lists, blockquotes +- Text formatting (bold, italic, underline, code) +- Dark mode styling + +#### 3. Custom Hook + +**useEpics** (`frontend/src/hooks/useEpics.ts`) +- Fetch all epics for a board +- Create new epic +- Update existing epic +- Delete epic +- Integrated with global loader and toast notifications +- Error handling with user-friendly messages + +#### 4. API Integration + +Updated `useApi` hook with epic methods: +```typescript +- getEpics(boardId) +- createEpic(boardId, epicData) +- getEpic(epicId) +- updateEpic(epicId, epicData) +- deleteEpic(epicId) +- addEpicToCard(cardId, epicId) +- removeEpicFromCard(cardId, epicId) +``` + +## Key Design Decisions + +### 1. Epic Hierarchy +- **Decision**: Self-referential foreign key (`parent_epic_id`) +- **Rationale**: Allows flexible nesting of epics with arbitrary depth +- **Feature**: `depth_limit` field to control maximum nesting depth + +### 2. Rich Text Storage +- **Decision**: Store as JSON (compatible with Slate.js) +- **Rationale**: + - Slate.js natively uses JSON format + - No serialization/deserialization overhead + - Easy to query and modify content structure + +### 3. Wiki vs Document Naming +- **Decision**: Use "Wiki" instead of "Document" +- **Rationale**: Avoids confusion with file attachments +- **Meaning**: Wiki implies knowledge base / documentation repository + +### 4. Entity Linking Strategy +- **Decision**: Polymorphic association table (`wiki_entity_links`) +- **Rationale**: + - Single table handles all entity types + - Easy to add new entity types in future + - Avoids circular imports and complex schema changes + +### 5. Epic-Card Relationship +- **Decision**: One-way reference (Card → Epic) +- **Rationale**: + - Simpler than many-to-many (epics contain cards) + - Cards can belong to one epic at a time + - Consistent with Jira's parent/child pattern + +## Architecture + +### Backend Data Flow + +``` +Board (1) ───────┬─────── (1) Epic + │ │ + │ │─── (0..*) Card (via epic_id) + │ + └───────────────┬─────── (0..*) Wiki + │ + └─── wiki_entity_links ───┬── Card + ├── Epic + └── (future entities) +``` + +### Frontend Component Structure + +``` +BoardEpics (page) +├── EpicList +│ └── EpicCard +└── CreateEpicModal + ├── EpicForm + │ ├── EpicNameInput + │ ├── EpicDescriptionInput + │ └── RichTextEditor (content) + └── ColorPicker +``` + +## Next Steps + +### Immediate Actions Required + +1. **Run Database Migration** + ```bash + cd backend + flask db upgrade + ``` + +2. **Create Backend Routes** (`backend/app/routes/kanban/epics.py`) + - CRUD operations for Epics + - Epic-Card linking endpoints + - Wiki CRUD operations + - Wiki-Entity linking endpoints + +3. **Create Backend Schemas** (`backend/app/schemas/epic.py`, `wiki.py`) + - Marshmallow schemas for serialization + - Input validation + +4. **Update BoardEpics Page** (`frontend/src/pages/BoardEpics.tsx`) + - Implement epic list view + - Add create epic modal + - Add epic detail view + - Include RichTextEditor for epic content + +5. **Update Card Detail Page** (`frontend/src/pages/CardDetail.tsx`) + - Add epic selector dropdown + - Display linked epic information + - Add wiki links + +### Future Enhancements + +1. **Wiki Features** + - Wiki page with sidebar navigation + - Markdown export/import + - Wiki search functionality + - Version history + +2. **Epic Features** + - Epic progress tracking (cards completed vs total) + - Epic metrics dashboard + - Epic drag-and-drop reordering + - Epic timeline view + +3. **UI/UX Improvements** + - Epic color picker with presets + - Epic hierarchy tree view + - Inline card epic assignment + - Epic templates + +4. **Analytics** + - Epic completion rate + - Time spent on epic + - Epic size distribution + - Wiki usage statistics + +## Database Schema + +### Epic Table +```sql +CREATE TABLE epics ( + id SERIAL PRIMARY KEY, + name VARCHAR(200) NOT NULL, + description TEXT, + content JSONB, + color VARCHAR(7), + closed BOOLEAN DEFAULT FALSE, + pos FLOAT, + depth_limit INTEGER, + board_id INTEGER REFERENCES boards(id), + parent_epic_id INTEGER REFERENCES epics(id), + date_last_activity TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +``` + +### Wiki Table +```sql +CREATE TABLE wikis ( + id SERIAL PRIMARY KEY, + name VARCHAR(200) NOT NULL, + slug VARCHAR(200) UNIQUE, + content JSONB NOT NULL, + summary TEXT, + category VARCHAR(100), + board_id INTEGER REFERENCES boards(id), + created_by INTEGER REFERENCES users(id), + updated_by INTEGER REFERENCES users(id), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +``` + +### Wiki Entity Links Table +```sql +CREATE TABLE wiki_entity_links ( + wiki_id INTEGER REFERENCES wikis(id) ON DELETE CASCADE, + entity_type VARCHAR(50) NOT NULL, + entity_id INTEGER NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + linked_by INTEGER REFERENCES users(id), + PRIMARY KEY (wiki_id, entity_type, entity_id) +); +``` + +## API Endpoints (To Be Implemented) + +### Epic Endpoints +``` +GET /api/boards//epics +POST /api/boards//epics +GET /api/epics/ +PUT /api/epics/ +DELETE /api/epics/ +``` + +### Epic-Card Linking +``` +POST /api/cards//epics +DELETE /api/cards//epics/ +``` + +### Wiki Endpoints +``` +GET /api/boards//wikis +POST /api/boards//wikis +GET /api/wikis/ +PUT /api/wikis/ +DELETE /api/wikis/ +GET /api/wikis//content +``` + +### Wiki-Entity Linking +``` +POST /api/wikis//links +DELETE /api/wikis//links// +``` + +## Testing Considerations + +### Unit Tests +- Epic model creation and relationships +- Wiki model creation and relationships +- Epic hierarchy validation +- Wiki slug generation +- Rich text content serialization + +### Integration Tests +- Epic CRUD operations +- Wiki CRUD operations +- Epic-Card linking +- Wiki-Entity linking +- Epic hierarchy operations + +### Frontend Tests +- RichTextEditor component +- RichTextContent component +- useEpics hook +- Epic list rendering +- Epic creation form + +## Notes + +- **Slate.js Content**: Content is stored as raw Slate.js JSON format, which includes element types, text nodes, and formatting information +- **Hierarchical Epics**: While supported, UI for nesting epics is not yet implemented +- **Wiki Slugs**: Slugs should be auto-generated from wiki names and checked for uniqueness +- **Color Codes**: Epic colors should be valid hex codes (e.g., "#ef4444") +- **Position**: Epic positions use float values for flexible reordering (similar to Trello) + +## Related Files + +### Backend +- `backend/app/models/epic.py` - Epic model +- `backend/app/models/wiki.py` - Wiki model +- `backend/app/models/card.py` - Updated Card model +- `backend/app/models/__init__.py` - Model imports +- `backend/migrations/versions/6fc439155ced_add_epic_and_wiki_models.py` - Migration + +### Frontend +- `frontend/src/types/epic.ts` - TypeScript interfaces +- `frontend/src/components/RichTextEditor.tsx` - Editor component +- `frontend/src/components/RichTextContent.tsx` - Content renderer +- `frontend/src/hooks/useEpics.ts` - Epic custom hook +- `frontend/src/hooks/useApi.ts` - Updated API methods \ No newline at end of file diff --git a/docs/EPIC_WIKI_MODELS_SUMMARY.md b/docs/EPIC_WIKI_MODELS_SUMMARY.md new file mode 100644 index 0000000..a0c261b --- /dev/null +++ b/docs/EPIC_WIKI_MODELS_SUMMARY.md @@ -0,0 +1,238 @@ +# Epic and Wiki Models - Implementation Summary + +## Overview +This document summarizes the implementation of Epic and Wiki models for the Kanban application. + +## What Was Implemented + +### 1. Epic Model (`backend/app/models/epic.py`) +- Tracks large features across multiple cards +- Hierarchical structure (parent-child epics) +- Rich text content support (Slate.js JSON) +- Color-coded badges for visual identification +- Metrics tracking (card count) + +**Key Fields:** +- `id`, `name`, `description`, `content` (JSONB) +- `color` (hex code for epic badge) +- `closed`, `pos`, `depth_limit` (default 5) +- `board_id`, `parent_epic_id` +- `date_last_activity`, `created_at`, `updated_at` +- `metrics` (JSONB - stores card_count) + +**Relationships:** +- Board: One-to-many (Board has many Epics) +- Cards: One-to-many (Epic has many Cards) +- Parent Epic: Self-referential (hierarchical) +- File Attachments: Polymorphic (like Cards) + +### 2. Wiki Model (`backend/app/models/wiki.py`) +- Reusable rich text content within a board +- Board-scoped (not global across all boards) +- Polymorphic links to entities (Card, Epic, etc.) +- Categorization and tagging support + +**Key Fields:** +- `id`, `name`, `slug` (URL-friendly) +- `content` (JSONB - rich text) +- `summary`, `category`, `tags` (JSONB) +- `board_id`, `created_by`, `updated_by` +- `created_at`, `updated_at` + +**Relationships:** +- Board: One-to-many (Board has many Wikis) +- Entities: Many-to-many polymorphic (via wiki_entity_links) + +### 3. Card Model Updates (`backend/app/models/card.py`) +- Added `epic_id` foreign key (nullable) +- Updated `to_dict()` to include `epic_id` +- One-to-one relationship: Card belongs to one Epic + +### 4. Association Table (`wiki_entity_links`) +- Polymorphic many-to-many table +- Links wikis to any entity type +- Fields: `wiki_id`, `entity_type`, `entity_id`, `created_at`, `linked_by` + +## Database Schema + +### New Tables +1. **epics** - Epic records +2. **wikis** - Wiki content records +3. **wiki_entity_links** - Wiki-to-entity associations + +### Modified Tables +1. **cards** - Added `epic_id` foreign key + +### Relationships Diagram +``` +Board (1) ----< (N) Epic +Epic (1) ----< (N) Card (each card belongs to one epic) +Epic (1) ----< (N) Epic (parent-child hierarchy) + +Board (1) ----< (N) Wiki +Wiki (M) ----> (M) Entity (polymorphic: Card, Epic) +``` + +## Database Migration + +**File:** `backend/migrations/versions/add_epic_and_wiki_models.py` + +**Creates:** +- `epics` table with indexes on `board_id`, `closed`, `name` +- `wikis` table with indexes on `board_id`, `name`, `slug` +- `wiki_entity_links` table with composite primary key +- `epic_id` column in `cards` table with index and foreign key + +**To apply migration:** +```bash +cd backend +flask db upgrade +``` + +**To rollback:** +```bash +flask db downgrade +``` + +## Model Exports + +Updated `backend/app/models/__init__.py` to include: +- `from app.models.epic import Epic` +- `from app.models.wiki import Wiki` + +Added to `__all__` list: `"Epic"`, `"Wiki"` + +## Design Decisions + +### Why "Wiki" instead of "Document"? +- Avoids confusion with file attachments +- Emphasizes reusable knowledge content +- Better semantic meaning for rich text resources + +### One-to-Many Epic-Card Relationship +- Simpler, clearer ownership +- Each card belongs to one epic +- Easier to query and display + +### Board-Scoped Wikis +- Wikis belong to a specific board +- Not global across all boards +- Better organization and access control + +### Epic Hierarchy Depth +- Default depth limit: 5 levels +- Configurable per epic +- Backend should enforce when creating child epics + +### Simplified Metrics +- Currently only tracks `card_count` +- Stored in JSONB field: `{"card_count": 10}` +- Easy to extend with more metrics later + +## Next Steps + +### Backend Implementation +1. ✅ Create models - **DONE** +2. ✅ Create database migration - **DONE** +3. ⏭️ Create schemas for serialization +4. ⏭️ Create API routes (CRUD operations) +5. ⏭️ Create services for business logic +6. ⏭️ Add validation for epic depth limit +7. ⏭️ Update epic metrics when cards change +8. ⏭️ Write tests for models and routes + +### Frontend Implementation +1. ⏭️ Update TypeScript types +2. ⏭️ Create Epic page/component +3. ⏭️ Create Wiki page/component +4. ⏭️ Add epic dropdown to card detail +5. ⏭️ Create epic list on board detail +6. ⏭️ Implement rich text editor (Slate.js) +7. ⏭️ Add wiki linking UI + +### API Endpoints (Future) + +#### Epic Endpoints +``` +GET /api/boards/{board_id}/epics # List all epics for board +POST /api/boards/{board_id}/epics # Create epic +GET /api/epics/{epic_id} # Get epic details with cards +PUT /api/epics/{epic_id} # Update epic +DELETE /api/epics/{epic_id} # Delete epic +POST /api/epics/{epic_id}/cards # Create card directly in epic +GET /api/epics/{epic_id}/tree # Get epic hierarchy tree +``` + +#### Wiki Endpoints +``` +GET /api/boards/{board_id}/wikis # List all wikis for board +POST /api/boards/{board_id}/wikis # Create wiki +GET /api/wikis/{wiki_id} # Get wiki details +PUT /api/wikis/{wiki_id} # Update wiki +DELETE /api/wikis/{wiki_id} # Delete wiki +POST /api/wikis/{wiki_id}/links # Link wiki to entity +DELETE /api/wikis/{wiki_id}/links/{link_id} # Unlink from entity +GET /api/{entity_type}/{entity_id}/wikis # Get wikis for entity +``` + +#### Card Endpoints (Updated) +``` +PUT /api/cards/{card_id}/epic # Link card to epic (or null to unlink) +GET /api/cards/{card_id}/epic # Get card's epic +``` + +## Files Created/Modified + +### Created +- `backend/app/models/epic.py` +- `backend/app/models/wiki.py` +- `backend/migrations/versions/add_epic_and_wiki_models.py` + +### Modified +- `backend/app/models/card.py` (added epic_id) +- `backend/app/models/__init__.py` (added Epic, Wiki imports) + +## Testing the Implementation + +1. **Apply migration:** +```bash +cd backend +source venv/bin/activate +flask db upgrade +``` + +2. **Verify tables created:** +```bash +flask dbcurrent +# Should show: add_epic_and_wiki_models +``` + +3. **Test in Python shell:** +```python +from app import create_app, db +from app.models import Epic, Wiki, Card, Board + +app = create_app() +with app.app_context(): + # Create an epic + epic = Epic(name="My Epic", board_id=1, color="#3b82f6") + db.session.add(epic) + db.session.commit() + + # Create a wiki + wiki = Wiki(name="Security Guide", board_id=1, content={}) + db.session.add(wiki) + db.session.commit() + + print(f"Epic created: {epic.to_dict()}") + print(f"Wiki created: {wiki.to_dict()}") +``` + +## Notes + +- All models follow the existing project patterns +- Uses `db` from `app` module (not `flask_sqlalchemy` directly) +- Proper foreign key constraints with CASCADE/SET NULL +- Timestamps use UTC timezone +- JSONB fields for flexible data storage +- Indexed for optimal query performance \ No newline at end of file diff --git a/docs/usage_rules_frontend.md b/docs/usage_rules_frontend.md index b255be0..efea99a 100644 --- a/docs/usage_rules_frontend.md +++ b/docs/usage_rules_frontend.md @@ -372,13 +372,13 @@ useEffect(() => { ``` ### Icons -- **ALWAYS** use inline SVG icons +- **ALWAYS** create icon components in `frontend/src/components/icons/` - **NEVER** use icon libraries like lucide-react, react-icons, or font-awesome -- Create reusable SVG icon components when needed -- SVGs should be defined as functional components +- Each icon should be a separate file with a default export +- Import icons from the icons folder ```jsx -// ✅ CORRECT - Inline SVG as a component +// ✅ CORRECT - Icon component in frontend/src/components/icons/TrashIcon.tsx const TrashIcon = () => ( @@ -386,6 +386,11 @@ const TrashIcon = () => ( ); +export default TrashIcon; + +// ✅ CORRECT - Using icon component +import TrashIcon from "../components/icons/TrashIcon"; + function DeleteButton() { return ( ); } + +// ❌ WRONG - Inline SVG in component file +function DeleteButton() { + return ( + + ); +} ``` -**Why inline SVGs?** +**Why use icon components in the icons folder?** 1. **No dependencies**: Reduces bundle size and eliminates external dependencies -2. **Full control**: You can customize SVG properties directly in JSX +2. **Full control**: You can customize SVG properties directly in the icon component 3. **Performance**: No runtime overhead from library wrappers -4. **Consistency**: All icons follow to same pattern and styling -5. **TypeScript support**: Full type safety without any issues +4. **Consistency**: All icons follow the same pattern and are organized in one place +5. **Reusability**: Icons can be easily imported and reused across the application +6. **Maintainability**: Icon components are centralized and easy to update +7. **TypeScript support**: Full type safety without any issues ## Routing Rules @@ -802,7 +824,7 @@ export function useNewContext() { ❌ **NEVER** hardcode API URLs ❌ **NEVER** skip PropTypes validation ❌ **NEVER** use `` for navigation (use ``) -❌ **NEVER** use icon libraries like lucide-react, react-icons, or font-awesome (always use inline SVGs) +❌ **NEVER** use icon libraries like lucide-react, react-icons, or font-awesome (always use icon components from frontend/src/components/icons/) ## Checklist Before Committing diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7342578..4888103 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -17,6 +17,9 @@ "react-dom": "^18.2.0", "react-hook-form": "^7.71.2", "react-router-dom": "^6.20.1", + "slate": "^0.123.0", + "slate-history": "^0.113.1", + "slate-react": "^0.123.0", "zod": "^4.3.6" }, "devDependencies": { @@ -1055,6 +1058,11 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@juggle/resize-observer": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@juggle/resize-observer/-/resize-observer-3.4.0.tgz", + "integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -2776,6 +2784,11 @@ "node": ">= 6" } }, + "node_modules/compute-scroll-into-view": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz", + "integrity": "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==" + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -3067,6 +3080,18 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/direction": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/direction/-/direction-1.0.4.tgz", + "integrity": "sha512-GYqKi1aH7PJXxdhTeZBFrg8vUBeKXi+cNprXsC1kpJcbcVnV9wBsrOu1cQEdG0WeQwlfHiy3XvnKfIrJ2R0NzQ==", + "bin": { + "direction": "cli.js" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/dlv": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", @@ -4496,6 +4521,11 @@ "node": ">=0.10.0" } }, + "node_modules/is-hotkey": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/is-hotkey/-/is-hotkey-0.2.0.tgz", + "integrity": "sha512-UknnZK4RakDmTgz4PI1wIph5yxSs/mvChWs9ifnlXsKuXgWmOkY/hAE0H/k2MIqH0RlRye0i1oC07MCRSD28Mw==" + }, "node_modules/is-map": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", @@ -4554,6 +4584,14 @@ "node": ">=8" } }, + "node_modules/is-plain-object": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", + "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -4931,6 +4969,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -6220,6 +6263,14 @@ "loose-envify": "^1.1.0" } }, + "node_modules/scroll-into-view-if-needed": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-3.1.0.tgz", + "integrity": "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==", + "dependencies": { + "compute-scroll-into-view": "^3.0.2" + } + }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -6400,6 +6451,59 @@ "node": ">= 10" } }, + "node_modules/slate": { + "version": "0.123.0", + "resolved": "https://registry.npmjs.org/slate/-/slate-0.123.0.tgz", + "integrity": "sha512-Oon3HR/QzJQBjuOUJT1jGGlp8Ff7t3Bkr/rJ2lDqxNT4H+cBnXpEVQ/si6hn1ZCHhD2xY/2N91PQoH/rD7kxTg==" + }, + "node_modules/slate-dom": { + "version": "0.123.0", + "resolved": "https://registry.npmjs.org/slate-dom/-/slate-dom-0.123.0.tgz", + "integrity": "sha512-OUinp4tvSrAlt64JL9y20Xin08jgnnj1gJmIuPdGvU5MELKXRNZh17a7EKKNOS6OZPAE8Dk9NI1MAIS/Qz0YBw==", + "peer": true, + "dependencies": { + "@juggle/resize-observer": "^3.4.0", + "direction": "^1.0.4", + "is-hotkey": "^0.2.0", + "is-plain-object": "^5.0.0", + "lodash": "^4.17.21", + "scroll-into-view-if-needed": "^3.1.0", + "tiny-invariant": "1.3.1" + }, + "peerDependencies": { + "slate": ">=0.121.0" + } + }, + "node_modules/slate-history": { + "version": "0.113.1", + "resolved": "https://registry.npmjs.org/slate-history/-/slate-history-0.113.1.tgz", + "integrity": "sha512-J9NSJ+UG2GxoW0lw5mloaKcN0JI0x2IA5M5FxyGiInpn+QEutxT1WK7S/JneZCMFJBoHs1uu7S7e6pxQjubHmQ==", + "dependencies": { + "is-plain-object": "^5.0.0" + }, + "peerDependencies": { + "slate": ">=0.65.3" + } + }, + "node_modules/slate-react": { + "version": "0.123.0", + "resolved": "https://registry.npmjs.org/slate-react/-/slate-react-0.123.0.tgz", + "integrity": "sha512-nQwXL1FEacrY9ZFmatRhoBnsySNUX2x6qB77V3oNHd7wWxBJWuzz4GMrBXcVoRE8Gac7Angf8xaNGzb6zcPlHg==", + "dependencies": { + "@juggle/resize-observer": "^3.4.0", + "direction": "^1.0.4", + "is-hotkey": "^0.2.0", + "lodash": "^4.17.21", + "scroll-into-view-if-needed": "^3.1.0", + "tiny-invariant": "1.3.1" + }, + "peerDependencies": { + "react": ">=18.2.0", + "react-dom": ">=18.2.0", + "slate": ">=0.121.0", + "slate-dom": ">=0.119.1" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -6744,6 +6848,11 @@ "node": ">=0.8" } }, + "node_modules/tiny-invariant": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.1.tgz", + "integrity": "sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw==" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index c176716..bd3397e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -23,6 +23,9 @@ "react-dom": "^18.2.0", "react-hook-form": "^7.71.2", "react-router-dom": "^6.20.1", + "slate": "^0.123.0", + "slate-history": "^0.113.1", + "slate-react": "^0.123.0", "zod": "^4.3.6" }, "devDependencies": { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 59e60db..913824c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -17,7 +17,15 @@ import { BoardCreate } from './pages/BoardCreate'; import { BoardEdit } from './pages/BoardEdit'; import { BoardDetail } from './pages/BoardDetail'; import { BoardEpics } from './pages/BoardEpics'; +import CreateEpic from './pages/CreateEpic'; +import { EpicDetail } from './pages/EpicDetail'; +import { EditEpic } from './pages/EditEpic'; +import { BoardWikis } from './pages/BoardWikis'; +import CreateWiki from './pages/CreateWiki'; +import { WikiDetail } from './pages/WikiDetail'; +import { EditWiki } from './pages/EditWiki'; import { CardDetail } from './pages/CardDetail'; +import { BoardDetailLayout } from './components/BoardDetailLayout'; const App = () => { const { token } = useApp(); @@ -73,7 +81,9 @@ const App = () => { path="/boards/:id/edit" element={ - + + + } /> @@ -81,7 +91,9 @@ const App = () => { path="/boards/:id" element={ - + + + } /> @@ -89,7 +101,79 @@ const App = () => { path="/boards/:id/epics" element={ - + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + } /> @@ -97,7 +181,9 @@ const App = () => { path="/boards/:id/cards/:cardId" element={ - + + + } /> diff --git a/frontend/src/components/BoardDetailLayout.tsx b/frontend/src/components/BoardDetailLayout.tsx new file mode 100644 index 0000000..3096ba4 --- /dev/null +++ b/frontend/src/components/BoardDetailLayout.tsx @@ -0,0 +1,18 @@ +import { ReactNode } from 'react'; +import { useParams } from 'react-router-dom'; +import { BoardSidebar } from './BoardSidebar'; + +export const BoardDetailLayout = ({ children }: { children: ReactNode }) => { + const { id } = useParams<{ id: string }>(); + + return ( +
+
{children}
+ {id && ( +
+ +
+ )} +
+ ); +}; diff --git a/frontend/src/components/BoardSidebar.tsx b/frontend/src/components/BoardSidebar.tsx index 248d0c3..c6e6c6d 100644 --- a/frontend/src/components/BoardSidebar.tsx +++ b/frontend/src/components/BoardSidebar.tsx @@ -9,32 +9,34 @@ export function BoardSidebar({ boardId }: BoardSidebarProps) { const menuItems = [ { id: 'epics', label: 'Epics', icon: '📋', path: `/boards/${boardId}/epics` }, + { id: 'wikis', label: 'Wikis', icon: '📚', path: `/boards/${boardId}/wikis` }, { id: 'history', label: 'History', icon: '📜', path: `/boards/${boardId}/history` }, - { id: 'documents', label: 'Documents', icon: '📄', path: `/boards/${boardId}/documents` }, ]; return ( -
-

Board Menu

- +
); } diff --git a/frontend/src/components/CardEpics.tsx b/frontend/src/components/CardEpics.tsx new file mode 100644 index 0000000..f16aac8 --- /dev/null +++ b/frontend/src/components/CardEpics.tsx @@ -0,0 +1,159 @@ +import { useState } from 'react'; +import { Link } from 'react-router-dom'; +import { useCardEpics } from '../hooks/useCardEpics'; +import type { EpicBasic } from '../types/kanban'; +import PlusIcon from './icons/PlusIcon'; +import UnlinkIcon from './icons/UnlinkIcon'; +import ChevronRightIcon from './icons/ChevronRightIcon'; +import CheckIcon from './icons/CheckIcon'; + +interface CardEpicsProps { + cardEpics: EpicBasic[]; + boardId: number; + cardId: number; + refetchCard: () => void; +} + +export function CardEpics({ cardEpics, boardId, cardId, refetchCard }: CardEpicsProps) { + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + + const { epics, addEpic, removeEpic } = useCardEpics(boardId, cardId); + + const handleAddEpic = async (epicId: number) => { + const success = await addEpic(epicId); + if (success) { + await refetchCard(); + setIsDropdownOpen(false); + } + }; + + const handleRemoveEpic = async (epicId: number, epicName: string) => { + if (!confirm(`Unlink epic "${epicName}" from this card?`)) { + return; + } + + const success = await removeEpic(epicId, epicName); + if (success) { + await refetchCard(); + } + }; + + // Filter out epics that are already linked to the card + const availableEpics = epics.filter( + (epic) => !cardEpics.some((cardEpic) => cardEpic.id === epic.id) + ); + + return ( +
+ {/* Header */} +
+

Epics

+ {cardEpics.length} linked +
+ + {/* Epics List */} +
+ {cardEpics.length > 0 ? ( + cardEpics.map((epic: EpicBasic) => ( + handleRemoveEpic(epic.id, epic.name)} + /> + )) + ) : ( +
+
📋
+

No epics linked yet

+
+ )} +
+ + {/* Add Epic Button */} + {availableEpics.length > 0 && ( +
+ + + {/* Dropdown */} + {isDropdownOpen && ( +
+ {availableEpics.map((epic: EpicBasic) => ( + + ))} +
+ )} +
+ )} +
+ ); +} + +interface EpicListItemProps { + epic: EpicBasic; + boardId: number; + onUnlink: () => void; +} + +function EpicListItem({ epic, boardId, onUnlink }: EpicListItemProps) { + const defaultColor = '#3b82f6'; + const color = epic.color || defaultColor; + + return ( +
+ +
+ {/* Color Indicator */} +
+ + {/* Epic Name */} +

{epic.name}

+ + {/* Chevron indicator */} + + + +
+ + + {/* Unlink Button (visible on hover) */} + +
+ ); +} diff --git a/frontend/src/components/CardPreviewModal.tsx b/frontend/src/components/CardPreviewModal.tsx index 5e3826f..1b1fa4d 100644 --- a/frontend/src/components/CardPreviewModal.tsx +++ b/frontend/src/components/CardPreviewModal.tsx @@ -12,6 +12,7 @@ export function CardPreviewModal({ card, onClose }: CardPreviewModalProps) {
{card.name} @@ -24,6 +25,24 @@ export function CardPreviewModal({ card, onClose }: CardPreviewModalProps) {
+ {/* Epic */} + {card.epic && ( +
+ + {card.epic.name} + +
+ )} + {/* Labels */} {card.labels && card.labels.length > 0 && (
diff --git a/frontend/src/components/ELEMENT_TAGS.ts b/frontend/src/components/ELEMENT_TAGS.ts new file mode 100644 index 0000000..c274184 --- /dev/null +++ b/frontend/src/components/ELEMENT_TAGS.ts @@ -0,0 +1,37 @@ +const ELEMENT_TAGS: Record Partial> = { + A: (el: HTMLElement) => ({ + type: 'link', + url: el.getAttribute('href') || '', + children: [], + }), + BLOCKQUOTE: () => ({ type: 'block-quote', children: [] }), + H1: () => ({ type: 'heading-one', children: [] }), + H2: () => ({ type: 'heading-two', children: [] }), + H3: () => ({ type: 'heading-three', children: [] }), + H4: () => ({ type: 'heading-four', children: [] }), + H5: () => ({ type: 'heading-five', children: [] }), + H6: () => ({ type: 'heading-six', children: [] }), + IMG: (el: HTMLElement) => ({ + type: 'image', + url: el.getAttribute('src') || '', + children: [], + }), + LI: () => ({ type: 'list-item', children: [] }), + OL: () => ({ type: 'numbered-list', children: [] }), + P: () => ({ type: 'paragraph', children: [] }), + PRE: () => ({ type: 'code-block', children: [] }), + CODE: () => ({ type: 'code-line bg-gray-900', children: [] }), + UL: () => ({ type: 'bulleted-list', children: [] }), +}; + +const TEXT_TAGS: Record Record> = { + CODE: () => ({ code: true }), + DEL: () => ({ strikethrough: true }), + EM: () => ({ italic: true }), + I: () => ({ italic: true }), + S: () => ({ strikethrough: true }), + STRONG: () => ({ bold: true }), + U: () => ({ underline: true }), +}; + +export { ELEMENT_TAGS, TEXT_TAGS }; diff --git a/frontend/src/components/RichTextContent.tsx b/frontend/src/components/RichTextContent.tsx new file mode 100644 index 0000000..c35a25e --- /dev/null +++ b/frontend/src/components/RichTextContent.tsx @@ -0,0 +1,34 @@ +import { useCallback, useMemo } from 'react'; +import { createEditor, Descendant } from 'slate'; +import { Slate, Editable, withReact, RenderElementProps, RenderLeafProps } from 'slate-react'; +import { Leaf, SlateRenderElement, withHtml } from './slate-editor-components'; + +export interface RichTextContentProps { + content: Descendant[] | null; + className?: string; +} + +const RichTextContent = ({ content }: any) => { + const editor = useMemo(() => withHtml(withReact(createEditor())), []); + + const renderElement = useCallback( + (props: RenderElementProps) => , + [] + ); + + const renderLeaf = useCallback((props: RenderLeafProps) => , []); + return ( + +
+ +
+
+ ); +}; + +export default RichTextContent; diff --git a/frontend/src/components/RichTextEditor.tsx b/frontend/src/components/RichTextEditor.tsx new file mode 100644 index 0000000..bc5a208 --- /dev/null +++ b/frontend/src/components/RichTextEditor.tsx @@ -0,0 +1,130 @@ +import React, { useMemo, useCallback } from 'react'; +import { createEditor, Descendant, Editor } from 'slate'; +import { + Slate, + Editable, + withReact as withReactPlugin, + useSlate, + RenderElementProps, + RenderLeafProps, +} from 'slate-react'; +import { withHistory } from 'slate-history'; +import { CustomEditor, CustomTextKey } from './custom-types'; +import { + BlockButton, + Button, + SlateIcon, + SlateIconType, + Leaf, + SlateRenderElement, + Toolbar, + withHtml, + Separator, +} from './slate-editor-components'; + +export interface RichTextEditorProps { + value: Descendant[]; + onChange: (value: Descendant[]) => void; + placeholder?: string; + readOnly?: boolean; + className?: string; +} + +interface MarkButtonProps { + format: CustomTextKey; + icon: SlateIconType; +} + +const isMarkActive = (editor: CustomEditor, format: CustomTextKey) => { + const marks = Editor.marks(editor); + return marks ? marks[format] === true : false; +}; + +const toggleMark = (editor: CustomEditor, format: CustomTextKey) => { + const isActive = isMarkActive(editor, format); + + if (isActive) { + Editor.removeMark(editor, format); + } else { + Editor.addMark(editor, format, true); + } +}; + +const MarkButton = ({ format, icon }: MarkButtonProps) => { + const editor = useSlate(); + return ( + + ); +}; + +const RichTextEditor: React.FC = ({ + value, + onChange, + placeholder = 'Type something...', + readOnly = false, + className = '', +}) => { + const editor = useMemo(() => withHtml(withHistory(withReactPlugin(createEditor()))), []); + + const renderElement = useCallback( + (props: RenderElementProps) => , + [] + ); + + // const renderElement = useCallback((props: any) => { + // switch (props.element.type) { + // case "block-quote": + // return
{props.children}
; + // case "bulleted-list": + // return
    {props.children}
; + // case "list-item": + // return
  • {props.children}
  • ; + // case "numbered-list": + // return
      {props.children}
    ; + // default: + // return

    {props.children}

    ; + // } + // }, []); + + const renderLeaf = useCallback((props: RenderLeafProps) => , []); + + return ( +
    + + + + + + + + + + + + + + + + + + + + +
    + ); +}; + +export default RichTextEditor; diff --git a/frontend/src/components/custom-types.d.ts b/frontend/src/components/custom-types.d.ts new file mode 100644 index 0000000..e8c5f45 --- /dev/null +++ b/frontend/src/components/custom-types.d.ts @@ -0,0 +1,196 @@ +import { Descendant, BaseEditor, BaseRange, Range, Element } from 'slate'; +import { ReactEditor } from 'slate-react'; +import { HistoryEditor } from 'slate-history'; + +export type BlockQuoteElement = { + type: 'block-quote'; + align?: string; + children: Descendant[]; +}; + +export type BulletedListElement = { + type: 'bulleted-list'; + align?: string; + children: Descendant[]; +}; + +export type CheckListItemElement = { + type: 'check-list-item'; + checked: boolean; + children: Descendant[]; +}; + +export type EditableVoidElement = { + type: 'editable-void'; + children: EmptyText[]; +}; + +export type HeadingElement = { + type: 'heading-one'; + align?: string; + children: Descendant[]; +}; + +export type HeadingTwoElement = { + type: 'heading-two'; + align?: string; + children: Descendant[]; +}; + +export type HeadingThreeElement = { + type: 'heading-three'; + align?: string; + children: Descendant[]; +}; + +export type HeadingFourElement = { + type: 'heading-four'; + align?: string; + children: Descendant[]; +}; + +export type HeadingFiveElement = { + type: 'heading-five'; + align?: string; + children: Descendant[]; +}; + +export type HeadingSixElement = { + type: 'heading-six'; + align?: string; + children: Descendant[]; +}; + +export type ImageElement = { + type: 'image'; + url: string; + children: EmptyText[]; +}; + +export type LinkElement = { type: 'link'; url: string; children: Descendant[] }; + +export type ButtonElement = { type: 'button'; children: Descendant[] }; + +export type BadgeElement = { type: 'badge'; children: Descendant[] }; + +export type ListItemElement = { type: 'list-item'; children: Descendant[] }; + +export type NumberedListItemElement = { + type: 'numbered-list'; + children: Descendant[]; +}; + +export type MentionElement = { + type: 'mention'; + character: string; + children: CustomText[]; +}; + +export type ParagraphElement = { + type: 'paragraph'; + align?: string; + children: Descendant[]; +}; + +export type TableElement = { type: 'table'; children: TableRow[] }; + +export type TableCellElement = { type: 'table-cell'; children: CustomText[] }; + +export type TableRowElement = { type: 'table-row'; children: TableCell[] }; + +export type TitleElement = { type: 'title'; children: Descendant[] }; + +export type VideoElement = { type: 'video'; url: string; children: EmptyText[] }; + +export type CodeBlockElement = { + type: 'code-block'; + language: string; + children: Descendant[]; +}; + +export type CodeLineElement = { + type: 'code-line'; + children: Descendant[]; +}; + +export type CustomElementWithAlign = + | ParagraphElement + | HeadingElement + | HeadingTwoElement + | HeadingThreeElement + | HeadingFourElement + | HeadingFiveElement + | HeadingSixElement + | BlockQuoteElement + | BulletedListElement; + +type CustomElement = + | BlockQuoteElement + | BulletedListElement + | CheckListItemElement + | EditableVoidElement + | HeadingElement + | HeadingTwoElement + | HeadingThreeElement + | HeadingFourElement + | HeadingFiveElement + | HeadingSixElement + | ImageElement + | LinkElement + | ButtonElement + | BadgeElement + | ListItemElement + | NumberedListItemElement + | MentionElement + | ParagraphElement + | TableElement + | TableRowElement + | TableCellElement + | TitleElement + | VideoElement + | CodeBlockElement + | CodeLineElement; + +export type CustomElementType = CustomElement['type']; + +export type CustomText = { + bold?: boolean; + italic?: boolean; + code?: boolean; + underline?: boolean; + strikethrough?: boolean; + // MARKDOWN PREVIEW SPECIFIC LEAF + underlined?: boolean; + title?: boolean; + list?: boolean; + hr?: boolean; + blockquote?: boolean; + text: string; +}; + +export type CustomTextKey = keyof Omit; + +export type EmptyText = { + text: string; +}; + +export type RenderElementPropsFor = RenderElementProps & { + element: T; +}; + +export type CustomEditor = BaseEditor & + ReactEditor & + HistoryEditor & { + nodeToDecorations?: Map; + }; + +declare module 'slate' { + interface CustomTypes { + Editor: CustomEditor; + Element: CustomElement; + Text: CustomText; + Range: BaseRange & { + [key: string]: unknown; + }; + } +} diff --git a/frontend/src/components/icons/CheckIcon.tsx b/frontend/src/components/icons/CheckIcon.tsx new file mode 100644 index 0000000..2009ca4 --- /dev/null +++ b/frontend/src/components/icons/CheckIcon.tsx @@ -0,0 +1,17 @@ +const CheckIcon = () => ( + + + +); + +export default CheckIcon; diff --git a/frontend/src/components/icons/ChevronRightIcon.tsx b/frontend/src/components/icons/ChevronRightIcon.tsx new file mode 100644 index 0000000..f66cd9f --- /dev/null +++ b/frontend/src/components/icons/ChevronRightIcon.tsx @@ -0,0 +1,17 @@ +const ChevronRightIcon = () => ( + + + +); + +export default ChevronRightIcon; diff --git a/frontend/src/components/icons/Edit2Icon.tsx b/frontend/src/components/icons/Edit2Icon.tsx index c07327f..25748d6 100644 --- a/frontend/src/components/icons/Edit2Icon.tsx +++ b/frontend/src/components/icons/Edit2Icon.tsx @@ -1,4 +1,8 @@ -const Edit2Icon = () => ( +interface Edit2IconProps { + className?: string; +} + +const Edit2Icon = ({ className = '' }: Edit2IconProps) => ( ( strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" + className={className} > diff --git a/frontend/src/components/icons/PlusIcon.tsx b/frontend/src/components/icons/PlusIcon.tsx index a336eb5..e4b3755 100644 --- a/frontend/src/components/icons/PlusIcon.tsx +++ b/frontend/src/components/icons/PlusIcon.tsx @@ -1,4 +1,8 @@ -const PlusIcon = () => ( +interface PlusIconProps { + className?: string; +} + +const PlusIcon = ({ className = '' }: PlusIconProps) => ( ( strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" + className={className} > diff --git a/frontend/src/components/icons/Trash2Icon.tsx b/frontend/src/components/icons/Trash2Icon.tsx index f5d15df..a72011f 100644 --- a/frontend/src/components/icons/Trash2Icon.tsx +++ b/frontend/src/components/icons/Trash2Icon.tsx @@ -1,4 +1,8 @@ -const Trash2Icon = () => ( +interface Trash2IconProps { + className?: string; +} + +const Trash2Icon = ({ className = '' }: Trash2IconProps) => ( ( strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" + className={className} > diff --git a/frontend/src/components/icons/UnlinkIcon.tsx b/frontend/src/components/icons/UnlinkIcon.tsx new file mode 100644 index 0000000..488a109 --- /dev/null +++ b/frontend/src/components/icons/UnlinkIcon.tsx @@ -0,0 +1,18 @@ +const UnlinkIcon = () => ( + + + + +); + +export default UnlinkIcon; diff --git a/frontend/src/components/isImageUrl.ts b/frontend/src/components/isImageUrl.ts new file mode 100644 index 0000000..363978e --- /dev/null +++ b/frontend/src/components/isImageUrl.ts @@ -0,0 +1,16 @@ +const isImageUrl = (url: string): boolean => { + if (!url) return false; + + try { + new URL(url); // Validate URL format + } catch { + return false; + } + + const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'ico', 'tiff', 'tif']; + const ext = url.split('.').pop()?.toLowerCase(); + + return ext ? imageExtensions.includes(ext) : false; +}; + +export default isImageUrl; diff --git a/frontend/src/components/kanban/KanbanCard.tsx b/frontend/src/components/kanban/KanbanCard.tsx index 30fbd1a..4459ed2 100644 --- a/frontend/src/components/kanban/KanbanCard.tsx +++ b/frontend/src/components/kanban/KanbanCard.tsx @@ -43,6 +43,10 @@ export function KanbanCard({ card, onOpenModal }: KanbanCardProps) { const labels = (card as any).labels || []; const hasLabels = labels.length > 0; + // Get epic + const epic = (card as any).epic; + const hasEpic = epic !== null && epic !== undefined; + return (
    + {/* Epic Badge */} + {hasEpic && ( +
    + + {epic.name} + +
    + )} + {/* Labels */} {hasLabels && (
    diff --git a/frontend/src/components/kanban/KanbanColumn.tsx b/frontend/src/components/kanban/KanbanColumn.tsx index 1fa86db..04f85e5 100644 --- a/frontend/src/components/kanban/KanbanColumn.tsx +++ b/frontend/src/components/kanban/KanbanColumn.tsx @@ -68,8 +68,8 @@ export function KanbanColumn({ }; return ( -
    -
    +
    +
    {/* Drag Handle Icon */} @@ -133,7 +133,7 @@ export function KanbanColumn({ >
    {cards.map((card) => ( onOpenCardModal(card)} /> diff --git a/frontend/src/components/slate-editor-components/index.tsx b/frontend/src/components/slate-editor-components/index.tsx new file mode 100644 index 0000000..a9ac33d --- /dev/null +++ b/frontend/src/components/slate-editor-components/index.tsx @@ -0,0 +1,630 @@ +import React, { ReactNode } from 'react'; +import ReactDOM from 'react-dom'; +import { RenderElementProps, RenderLeafProps, useSlate } from 'slate-react'; +import { + CustomEditor, + CustomElement, + CustomElementType, + CustomElementWithAlign, +} from '../custom-types'; +import { Editor, Node, Transforms, Element as SlateElement } from 'slate'; +import { ELEMENT_TAGS, TEXT_TAGS } from '../ELEMENT_TAGS'; + +// --- Icon Types --- +export type SlateIconType = + | 'format_bold' + | 'format_italic' + | 'format_underlined' + | 'code' + | 'looks_one' + | 'looks_two' + | 'format_quote' + | 'format_list_numbered' + | 'format_list_bulleted' + | 'format_align_left' + | 'format_align_center' + | 'format_align_right' + | 'format_align_justify'; + +// --- SVG Icons --- +const FormatBoldIcon = () => ( + + + + +); + +const FormatItalicIcon = () => ( + + + + + +); + +const FormatUnderlinedIcon = () => ( + + + + +); + +const CodeIcon = () => ( + + + + +); + +const LooksOneIcon = () => ( + + + + + +); + +const LooksTwoIcon = () => ( + + + + + + +); + +const FormatQuoteIcon = () => ( + + + + +); + +const FormatListNumberedIcon = () => ( + + + + + + + + +); + +const FormatListBulletedIcon = () => ( + + + + + + + + + + + + + + +); + +const FormatAlignLeftIcon = () => ( + + + + + + +); + +const FormatAlignCenterIcon = () => ( + + + + + + +); + +const FormatAlignRightIcon = () => ( + + + + + + +); + +const FormatAlignJustifyIcon = () => ( + + + + + + +); + +// --- Icon Mapping --- +const iconMap: Record = { + format_bold: FormatBoldIcon, + format_italic: FormatItalicIcon, + format_underlined: FormatUnderlinedIcon, + code: CodeIcon, + looks_one: LooksOneIcon, + looks_two: LooksTwoIcon, + format_quote: FormatQuoteIcon, + format_list_numbered: FormatListNumberedIcon, + format_list_bulleted: FormatListBulletedIcon, + format_align_left: FormatAlignLeftIcon, + format_align_center: FormatAlignCenterIcon, + format_align_right: FormatAlignRightIcon, + format_align_justify: FormatAlignJustifyIcon, +}; + +export const deserialize = (node: any): any[] => { + if (node.nodeType === 3) { + return [{ text: node.textContent }]; + } else if (node.nodeType !== 1) { + return []; + } + + const el = node as any; + const children = Array.from(node.childNodes).map(deserialize).flat(); + + if (el.nodeName === 'BODY') { + return children; + } + + if (ELEMENT_TAGS[el.nodeName]) { + const attrs = ELEMENT_TAGS[el.nodeName](el); + return [{ ...attrs, children }]; + } + + if (TEXT_TAGS[el.nodeName]) { + const attrs = TEXT_TAGS[el.nodeName](); + return children.map((child) => ({ ...child, ...attrs })); + } + + return children; +}; + +const LIST_TYPES = ['numbered-list', 'bulleted-list'] as const; +const TEXT_ALIGN_TYPES = ['left', 'center', 'right', 'justify'] as const; + +type AlignType = (typeof TEXT_ALIGN_TYPES)[number]; + +// --- Interfaces --- + +// Extend standard HTML attributes to include className, id, etc. automatically +interface ButtonProps extends React.ButtonHTMLAttributes { + active: boolean; + reversed?: boolean; +} + +type ListType = (typeof LIST_TYPES)[number]; +type CustomElementFormat = CustomElementType | AlignType | ListType; + +const isAlignType = (format: CustomElementFormat): format is AlignType => { + return TEXT_ALIGN_TYPES.includes(format as AlignType); +}; + +const isListType = (format: CustomElementFormat): format is ListType => { + return LIST_TYPES.includes(format as ListType); +}; + +// --- Components --- + +export const Button = React.forwardRef( + ({ className, active, ...props }, ref) => { + // Determine color based on active state for dark theme + const colorClass = active + ? 'text-blue-400 bg-gray-700/50' + : 'text-gray-400 hover:text-gray-200 hover:bg-gray-700/30'; + + return ( + + ); +}; + +export const withHtml = (editor: CustomEditor) => { + const { insertData, isInline, isVoid } = editor; + + editor.isInline = (element: any) => { + return element.type === 'link' ? true : isInline(element); + }; + + editor.isVoid = (element: any) => { + return element.type === 'image' ? true : isVoid(element); + }; + + editor.insertData = (data) => { + const html = data.getData('text/html'); + + if (html) { + const parsed = new DOMParser().parseFromString(html, 'text/html'); + const fragment = deserialize(parsed.body); + Transforms.insertFragment(editor, fragment); + return; + } + + insertData(data); + }; + + return editor; +}; diff --git a/frontend/src/hooks/useApi.ts b/frontend/src/hooks/useApi.ts index 3deb170..c2b6a0e 100644 --- a/frontend/src/hooks/useApi.ts +++ b/frontend/src/hooks/useApi.ts @@ -299,6 +299,55 @@ export function useApi() { }); return response.data; }, + + // Epics + getEpics: async (boardId: number): Promise => { + const response = await api.get(`/boards/${boardId}/epics`); + return response.data; + }, + createEpic: async (boardId: number, epicData: any): Promise => { + const response = await api.post(`/boards/${boardId}/epics`, epicData); + return response.data; + }, + getEpic: async (epicId: number): Promise => { + const response = await api.get(`/epics/${epicId}`); + return response.data; + }, + updateEpic: async (epicId: number, epicData: any): Promise => { + const response = await api.put(`/epics/${epicId}`, epicData); + return response.data; + }, + deleteEpic: async (epicId: number): Promise => { + await api.delete(`/epics/${epicId}`); + }, + addEpicToCard: async (cardId: number, epicId: number): Promise => { + const response = await api.post(`/cards/${cardId}/epics`, { epic_id: epicId }); + return response.data; + }, + removeEpicFromCard: async (cardId: number, epicId: number): Promise => { + await api.delete(`/cards/${cardId}/epics/${epicId}`); + }, + + // Wikis + getWikis: async (boardId: number): Promise => { + const response = await api.get(`/boards/${boardId}/wikis`); + return response.data; + }, + createWiki: async (boardId: number, wikiData: any): Promise => { + const response = await api.post(`/boards/${boardId}/wikis`, wikiData); + return response.data; + }, + getWiki: async (wikiId: number): Promise => { + const response = await api.get(`/wikis/${wikiId}`); + return response.data; + }, + updateWiki: async (wikiId: number, wikiData: any): Promise => { + const response = await api.put(`/wikis/${wikiId}`, wikiData); + return response.data; + }, + deleteWiki: async (wikiId: number): Promise => { + await api.delete(`/wikis/${wikiId}`); + }, }), [] ); diff --git a/frontend/src/hooks/useCardEpics.ts b/frontend/src/hooks/useCardEpics.ts new file mode 100644 index 0000000..f2e2a37 --- /dev/null +++ b/frontend/src/hooks/useCardEpics.ts @@ -0,0 +1,100 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useApi } from './useApi'; +import { useLoader } from '../context/loaders/useLoader'; +import { useToast } from '../context/toasts/useToast'; +import type { EpicBasic } from '../types/kanban'; + +export function useCardEpics(boardId: number, cardId: number) { + const [epics, setEpics] = useState([]); + const [error, setError] = useState(null); + + const { getEpics, addEpicToCard, removeEpicFromCard } = useApi(); + const { withLoader } = useLoader(); + const { addNotification } = useToast(); + + const fetchEpics = useCallback(async () => { + try { + setError(null); + const data = await getEpics(boardId); + setEpics(data); + return data; + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to load epics'; + setError(err instanceof Error ? err : new Error(errorMessage)); + addNotification({ + type: 'error', + title: 'Error Loading Epics', + message: errorMessage, + duration: 5000, + }); + return []; + } + }, [getEpics, boardId, addNotification]); + + const addEpic = useCallback( + async (epicId: number) => { + try { + setError(null); + await withLoader(() => addEpicToCard(cardId, epicId), 'Linking epic...'); + addNotification({ + type: 'success', + title: 'Epic Linked', + message: 'Epic linked to card successfully.', + duration: 3000, + }); + return true; + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to link epic'; + setError(err instanceof Error ? err : new Error(errorMessage)); + addNotification({ + type: 'error', + title: 'Error Linking Epic', + message: errorMessage, + duration: 5000, + }); + return false; + } + }, + [cardId, addEpicToCard, withLoader, addNotification] + ); + + const removeEpic = useCallback( + async (epicId: number) => { + try { + setError(null); + await withLoader(() => removeEpicFromCard(cardId, epicId), 'Unlinking epic...'); + addNotification({ + type: 'success', + title: 'Epic Unlinked', + message: 'Epic unlinked from card successfully.', + duration: 3000, + }); + return true; + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to unlink epic'; + setError(err instanceof Error ? err : new Error(errorMessage)); + addNotification({ + type: 'error', + title: 'Error Unlinking Epic', + message: errorMessage, + duration: 5000, + }); + return false; + } + }, + [cardId, removeEpicFromCard, withLoader, addNotification] + ); + + useEffect(() => { + fetchEpics(); + }, [fetchEpics]); + + return { + epics, + error, // For debugging, not for UI display + loading: false, // Loading is handled by global loader + fetchEpics, + addEpic, + removeEpic, + }; +} diff --git a/frontend/src/hooks/useEpicDetail.ts b/frontend/src/hooks/useEpicDetail.ts new file mode 100644 index 0000000..d8dbe28 --- /dev/null +++ b/frontend/src/hooks/useEpicDetail.ts @@ -0,0 +1,105 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useApi } from './useApi'; +import { useLoader } from '../context/loaders/useLoader'; +import { useToast } from '../context/toasts/useToast'; +import type { Epic, UpdateEpicRequest } from '../types/epic'; + +function useEpicDetail(epicId: string) { + const [epic, setEpic] = useState(null); + const [error, setError] = useState(null); + + const { getEpic, updateEpic: apiUpdateEpic, deleteEpic: apiDeleteEpic } = useApi(); + const { withLoader } = useLoader(); + const { addNotification } = useToast(); + + const fetchEpic = useCallback(async () => { + try { + setError(null); + const data = await withLoader(() => getEpic(Number(epicId)), 'Loading epic...'); + setEpic(data); + return data; + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to load epic'; + setError(err instanceof Error ? err : new Error(errorMessage)); + addNotification({ + type: 'error', + title: 'Error Loading Epic', + message: errorMessage, + duration: 5000, + }); + return null; + } + }, [epicId, getEpic, withLoader, addNotification]); + + const updateEpic = useCallback( + async (epicData: UpdateEpicRequest) => { + try { + setError(null); + const updatedEpic = await withLoader( + () => apiUpdateEpic(Number(epicId), epicData), + 'Updating epic...' + ); + setEpic(updatedEpic); + addNotification({ + type: 'success', + title: 'Epic Updated', + message: 'Epic updated successfully.', + duration: 3000, + }); + return updatedEpic; + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to update epic'; + setError(err instanceof Error ? err : new Error(errorMessage)); + addNotification({ + type: 'error', + title: 'Error Updating Epic', + message: errorMessage, + duration: 5000, + }); + throw err; + } + }, + [epicId, apiUpdateEpic, withLoader, addNotification] + ); + + const deleteEpic = useCallback(async () => { + try { + setError(null); + await withLoader(() => apiDeleteEpic(Number(epicId)), 'Deleting epic...'); + addNotification({ + type: 'success', + title: 'Epic Deleted', + message: 'Epic deleted successfully.', + duration: 3000, + }); + return true; + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to delete epic'; + setError(err instanceof Error ? err : new Error(errorMessage)); + addNotification({ + type: 'error', + title: 'Error Deleting Epic', + message: errorMessage, + duration: 5000, + }); + throw err; + } + }, [epicId, apiDeleteEpic, withLoader, addNotification]); + + useEffect(() => { + if (epicId) { + fetchEpic(); + } + }, [epicId, fetchEpic]); + + return { + epic, + error, // For debugging, not for UI display + loading: false, // Loading is handled by global loader + updateEpic, + deleteEpic, + refetch: fetchEpic, + }; +} + +export default useEpicDetail; diff --git a/frontend/src/hooks/useEpics.ts b/frontend/src/hooks/useEpics.ts new file mode 100644 index 0000000..bc4a0c6 --- /dev/null +++ b/frontend/src/hooks/useEpics.ts @@ -0,0 +1,144 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useApi } from './useApi'; +import { useLoader } from '../context/loaders/useLoader'; +import { useToast } from '../context/toasts/useToast'; +import type { Epic, CreateEpicRequest, UpdateEpicRequest } from '../types/epic'; + +function useEpics(boardId: string) { + const [epics, setEpics] = useState([]); + const [error, setError] = useState(null); + + const { + getEpics, + createEpic: apiCreateEpic, + updateEpic: apiUpdateEpic, + deleteEpic: apiDeleteEpic, + } = useApi(); + const { withLoader } = useLoader(); + const { addNotification } = useToast(); + + const fetchEpics = useCallback(async () => { + try { + setError(null); + const data = await withLoader(() => getEpics(Number(boardId)), 'Loading epics...'); + setEpics(data); + return data; + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to load epics'; + setError(err instanceof Error ? err : new Error(errorMessage)); + addNotification({ + type: 'error', + title: 'Error Loading Epics', + message: errorMessage, + duration: 5000, + }); + return []; + } + }, [boardId, getEpics, withLoader, addNotification]); + + const createEpic = useCallback( + async (epicData: CreateEpicRequest) => { + try { + setError(null); + const newEpic = await withLoader( + () => apiCreateEpic(Number(boardId), epicData), + 'Creating epic...' + ); + setEpics((prev) => [...prev, newEpic]); + addNotification({ + type: 'success', + title: 'Epic Created', + message: `Epic "${epicData.name}" created successfully.`, + duration: 3000, + }); + return newEpic; + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to create epic'; + setError(err instanceof Error ? err : new Error(errorMessage)); + addNotification({ + type: 'error', + title: 'Error Creating Epic', + message: errorMessage, + duration: 5000, + }); + throw err; + } + }, + [boardId, apiCreateEpic, withLoader, addNotification] + ); + + const updateEpic = useCallback( + async (epicId: number, epicData: UpdateEpicRequest) => { + try { + setError(null); + const updatedEpic = await withLoader( + () => apiUpdateEpic(epicId, epicData), + 'Updating epic...' + ); + setEpics((prev) => prev.map((epic) => (epic.id === epicId ? updatedEpic : epic))); + addNotification({ + type: 'success', + title: 'Epic Updated', + message: 'Epic updated successfully.', + duration: 3000, + }); + return updatedEpic; + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to update epic'; + setError(err instanceof Error ? err : new Error(errorMessage)); + addNotification({ + type: 'error', + title: 'Error Updating Epic', + message: errorMessage, + duration: 5000, + }); + throw err; + } + }, + [apiUpdateEpic, withLoader, addNotification] + ); + + const deleteEpic = useCallback( + async (epicId: number) => { + try { + setError(null); + await withLoader(() => apiDeleteEpic(epicId), 'Deleting epic...'); + setEpics((prev) => prev.filter((epic) => epic.id !== epicId)); + addNotification({ + type: 'success', + title: 'Epic Deleted', + message: 'Epic deleted successfully.', + duration: 3000, + }); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to delete epic'; + setError(err instanceof Error ? err : new Error(errorMessage)); + addNotification({ + type: 'error', + title: 'Error Deleting Epic', + message: errorMessage, + duration: 5000, + }); + throw err; + } + }, + [apiDeleteEpic, withLoader, addNotification] + ); + + useEffect(() => { + fetchEpics(); + }, [fetchEpics]); + + return { + epics, + error, // For debugging, not for UI display + loading: false, // Loading is handled by global loader + fetchEpics, + createEpic, + updateEpic, + deleteEpic, + refetch: fetchEpics, + }; +} + +export default useEpics; diff --git a/frontend/src/hooks/useWikiDetail.ts b/frontend/src/hooks/useWikiDetail.ts new file mode 100644 index 0000000..b4c2e23 --- /dev/null +++ b/frontend/src/hooks/useWikiDetail.ts @@ -0,0 +1,105 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useApi } from './useApi'; +import { useLoader } from '../context/loaders/useLoader'; +import { useToast } from '../context/toasts/useToast'; +import type { Wiki, UpdateWikiRequest } from '../types/epic'; + +function useWikiDetail(wikiId: string) { + const [wiki, setWiki] = useState(null); + const [error, setError] = useState(null); + + const { getWiki, updateWiki: apiUpdateWiki, deleteWiki: apiDeleteWiki } = useApi(); + const { withLoader } = useLoader(); + const { addNotification } = useToast(); + + const fetchWiki = useCallback(async () => { + try { + setError(null); + const data = await withLoader(() => getWiki(Number(wikiId)), 'Loading wiki...'); + setWiki(data); + return data; + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to load wiki'; + setError(err instanceof Error ? err : new Error(errorMessage)); + addNotification({ + type: 'error', + title: 'Error Loading Wiki', + message: errorMessage, + duration: 5000, + }); + return null; + } + }, [wikiId, getWiki, withLoader, addNotification]); + + const updateWiki = useCallback( + async (wikiData: UpdateWikiRequest) => { + try { + setError(null); + const updatedWiki = await withLoader( + () => apiUpdateWiki(Number(wikiId), wikiData), + 'Updating wiki...' + ); + setWiki(updatedWiki); + addNotification({ + type: 'success', + title: 'Wiki Updated', + message: 'Wiki updated successfully.', + duration: 3000, + }); + return updatedWiki; + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to update wiki'; + setError(err instanceof Error ? err : new Error(errorMessage)); + addNotification({ + type: 'error', + title: 'Error Updating Wiki', + message: errorMessage, + duration: 5000, + }); + throw err; + } + }, + [wikiId, apiUpdateWiki, withLoader, addNotification] + ); + + const deleteWiki = useCallback(async () => { + try { + setError(null); + await withLoader(() => apiDeleteWiki(Number(wikiId)), 'Deleting wiki...'); + addNotification({ + type: 'success', + title: 'Wiki Deleted', + message: 'Wiki deleted successfully.', + duration: 3000, + }); + return true; + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to delete wiki'; + setError(err instanceof Error ? err : new Error(errorMessage)); + addNotification({ + type: 'error', + title: 'Error Deleting Wiki', + message: errorMessage, + duration: 5000, + }); + throw err; + } + }, [wikiId, apiDeleteWiki, withLoader, addNotification]); + + useEffect(() => { + if (wikiId) { + fetchWiki(); + } + }, [wikiId, fetchWiki]); + + return { + wiki, + error, // For debugging, not for UI display + loading: false, // Loading is handled by global loader + updateWiki, + deleteWiki, + refetch: fetchWiki, + }; +} + +export default useWikiDetail; diff --git a/frontend/src/hooks/useWikis.ts b/frontend/src/hooks/useWikis.ts new file mode 100644 index 0000000..6a2dce9 --- /dev/null +++ b/frontend/src/hooks/useWikis.ts @@ -0,0 +1,144 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useApi } from './useApi'; +import { useLoader } from '../context/loaders/useLoader'; +import { useToast } from '../context/toasts/useToast'; +import type { Wiki, CreateWikiRequest, UpdateWikiRequest } from '../types/epic'; + +function useWikis(boardId: string) { + const [wikis, setWikis] = useState([]); + const [error, setError] = useState(null); + + const { + getWikis, + createWiki: apiCreateWiki, + updateWiki: apiUpdateWiki, + deleteWiki: apiDeleteWiki, + } = useApi(); + const { withLoader } = useLoader(); + const { addNotification } = useToast(); + + const fetchWikis = useCallback(async () => { + try { + setError(null); + const data = await withLoader(() => getWikis(Number(boardId)), 'Loading wikis...'); + setWikis(data); + return data; + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to load wikis'; + setError(err instanceof Error ? err : new Error(errorMessage)); + addNotification({ + type: 'error', + title: 'Error Loading Wikis', + message: errorMessage, + duration: 5000, + }); + return []; + } + }, [boardId, getWikis, withLoader, addNotification]); + + const createWiki = useCallback( + async (wikiData: CreateWikiRequest) => { + try { + setError(null); + const newWiki = await withLoader( + () => apiCreateWiki(Number(boardId), wikiData), + 'Creating wiki...' + ); + setWikis((prev) => [...prev, newWiki]); + addNotification({ + type: 'success', + title: 'Wiki Created', + message: `Wiki "${wikiData.name}" created successfully.`, + duration: 3000, + }); + return newWiki; + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to create wiki'; + setError(err instanceof Error ? err : new Error(errorMessage)); + addNotification({ + type: 'error', + title: 'Error Creating Wiki', + message: errorMessage, + duration: 5000, + }); + throw err; + } + }, + [boardId, apiCreateWiki, withLoader, addNotification] + ); + + const updateWiki = useCallback( + async (wikiId: number, wikiData: UpdateWikiRequest) => { + try { + setError(null); + const updatedWiki = await withLoader( + () => apiUpdateWiki(wikiId, wikiData), + 'Updating wiki...' + ); + setWikis((prev) => prev.map((wiki) => (wiki.id === wikiId ? updatedWiki : wiki))); + addNotification({ + type: 'success', + title: 'Wiki Updated', + message: 'Wiki updated successfully.', + duration: 3000, + }); + return updatedWiki; + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to update wiki'; + setError(err instanceof Error ? err : new Error(errorMessage)); + addNotification({ + type: 'error', + title: 'Error Updating Wiki', + message: errorMessage, + duration: 5000, + }); + throw err; + } + }, + [apiUpdateWiki, withLoader, addNotification] + ); + + const deleteWiki = useCallback( + async (wikiId: number) => { + try { + setError(null); + await withLoader(() => apiDeleteWiki(wikiId), 'Deleting wiki...'); + setWikis((prev) => prev.filter((wiki) => wiki.id !== wikiId)); + addNotification({ + type: 'success', + title: 'Wiki Deleted', + message: 'Wiki deleted successfully.', + duration: 3000, + }); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to delete wiki'; + setError(err instanceof Error ? err : new Error(errorMessage)); + addNotification({ + type: 'error', + title: 'Error Deleting Wiki', + message: errorMessage, + duration: 5000, + }); + throw err; + } + }, + [apiDeleteWiki, withLoader, addNotification] + ); + + useEffect(() => { + fetchWikis(); + }, [fetchWikis]); + + return { + wikis, + error, // For debugging, not for UI display + loading: false, // Loading is handled by global loader + fetchWikis, + createWiki, + updateWiki, + deleteWiki, + refetch: fetchWikis, + }; +} + +export default useWikis; diff --git a/frontend/src/index.css b/frontend/src/index.css index b5c61c9..63d4e53 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,3 +1,28 @@ @tailwind base; @tailwind components; @tailwind utilities; + +/* Custom scrollbar styles for dark theme */ +.scrollbar-custom::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +.scrollbar-custom::-webkit-scrollbar-track { + background: #1f2937; /* Tailwind gray-800 - dark track */ + border-radius: 4px; +} + +.scrollbar-custom::-webkit-scrollbar-thumb { + background: #4b5563; /* Tailwind gray-600 - medium thumb */ + border-radius: 4px; +} + +.scrollbar-custom::-webkit-scrollbar-thumb:hover { + background: #6b7280; /* Tailwind gray-500 - lighter on hover */ +} + +.scrollbar-custom { + scrollbar-width: thin; + scrollbar-color: #4b5563 #1f2937; /* thumb, track - dark theme colors */ +} diff --git a/frontend/src/pages/BoardDetail.tsx b/frontend/src/pages/BoardDetail.tsx index a49d6ee..aae1b14 100644 --- a/frontend/src/pages/BoardDetail.tsx +++ b/frontend/src/pages/BoardDetail.tsx @@ -5,7 +5,6 @@ import { useListMutations } from '../hooks/useListMutations'; import { SortableKanbanColumn } from '../components/kanban/SortableKanbanColumn'; import { CreateListModal } from '../components/kanban/CreateListModal'; import { CardPreviewModal } from '../components/CardPreviewModal'; -import { BoardSidebar } from '../components/BoardSidebar'; import { useModal } from '../context/modals/useModal'; import { DndContext, @@ -244,7 +243,7 @@ export function BoardDetail() {
    -
    +
    - -
    diff --git a/frontend/src/pages/BoardEpics.tsx b/frontend/src/pages/BoardEpics.tsx index 76df061..f04dcce 100644 --- a/frontend/src/pages/BoardEpics.tsx +++ b/frontend/src/pages/BoardEpics.tsx @@ -1,35 +1,183 @@ -import { useParams, Link } from 'react-router-dom'; +import { useParams, Link, useNavigate } from 'react-router-dom'; import { WidePageLayout } from '../components/WidePageLayout'; +import useEpics from '../hooks/useEpics'; +import RichTextContent from '../components/RichTextContent'; +import type { Epic } from '../types/epic'; +import PlusIcon from '../components/icons/PlusIcon'; +import Edit2Icon from '../components/icons/Edit2Icon'; +import Trash2Icon from '../components/icons/Trash2Icon'; export function BoardEpics() { const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const { epics, deleteEpic } = useEpics(id || '0'); + + const handleDeleteEpic = async (epicId: number, epicName: string) => { + if (confirm(`Are you sure you want to delete epic "${epicName}"?`)) { + try { + await deleteEpic(epicId); + } catch { + // Error is handled by the hook + } + } + }; return (
    -
    +
    +
    + + ← Back to Board + +

    Epics

    +

    + Manage and view epics for this board ({epics.length} epics) +

    +
    - ← Back to Board + + Create Epic -

    Epics

    -

    Manage and view epics for this board

    -
    -
    -
    📋
    -

    Epics Coming Soon

    -

    - This page will allow you to create and manage epics for your board. -

    + {epics.length === 0 ? ( +
    +
    +
    📋
    +

    No Epics Yet

    +

    + Create your first epic to start organizing your work. +

    + + + Create First Epic + +
    -
    + ) : ( +
    + {epics.map((epic) => ( + navigate(`/boards/${id}/epics/${epic.id}`)} + onDelete={() => handleDeleteEpic(epic.id, epic.name)} + onEdit={() => navigate(`/boards/${id}/epics/${epic.id}/edit`)} + /> + ))} +
    + )}
    ); } + +interface EpicCardProps { + epic: Epic; + onClick: () => void; + onDelete: () => void; + onEdit: () => void; +} + +function EpicCard({ epic, onClick, onDelete, onEdit }: EpicCardProps) { + const defaultColor = '#3b82f6'; + const color = epic.color || defaultColor; + + return ( +
    + {/* Color header */} +
    + +
    + {/* Header with actions */} +
    +

    {epic.name}

    +
    + + +
    +
    + + {/* Description */} + {epic.description && ( +

    {epic.description}

    + )} + + {/* Rich text content preview */} + {epic.content && Array.isArray(epic.content) && epic.content.length > 0 && ( +
    + + {/* Fade overlay at bottom */} +
    +
    + )} + + {/* Footer with metrics */} +
    +
    + + 📊 + {epic.metrics?.card_count || 0} cards + + {epic.parent_epic_id && ( + + 🔄 + Sub-epic + + )} +
    + {epic.date_last_activity && ( + {new Date(epic.date_last_activity).toLocaleDateString()} + )} +
    + + {/* Closed badge */} + {epic.closed && ( +
    + + Closed + +
    + )} +
    +
    + ); +} + +export default BoardEpics; diff --git a/frontend/src/pages/BoardWikis.tsx b/frontend/src/pages/BoardWikis.tsx new file mode 100644 index 0000000..4bb0357 --- /dev/null +++ b/frontend/src/pages/BoardWikis.tsx @@ -0,0 +1,158 @@ +import { useParams, Link, useNavigate } from 'react-router-dom'; +import { WidePageLayout } from '../components/WidePageLayout'; +import RichTextContent from '../components/RichTextContent'; +import useWikis from '../hooks/useWikis'; +import type { Wiki } from '../types/epic'; +import PlusIcon from '../components/icons/PlusIcon'; +import Edit2Icon from '../components/icons/Edit2Icon'; +import Trash2Icon from '../components/icons/Trash2Icon'; + +export function BoardWikis() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const { wikis, deleteWiki } = useWikis(id || '0'); + + const handleDeleteWiki = async (wikiId: number, wikiName: string) => { + if (confirm(`Are you sure you want to delete wiki "${wikiName}"?`)) { + try { + await deleteWiki(wikiId); + } catch { + // Error is handled by the hook + } + } + }; + + return ( +
    + +
    +
    + + ← Back to Board + +

    Wikis

    +

    + Manage and view wikis for this board ({wikis.length} wikis) +

    +
    + + + Create Wiki + +
    +
    + + + {wikis.length === 0 ? ( +
    +
    +
    📚
    +

    No Wikis Yet

    +

    + Create your first wiki to start documenting your knowledge. +

    + + + Create First Wiki + +
    +
    + ) : ( +
    + {wikis.map((wiki) => ( + navigate(`/boards/${id}/wikis/${wiki.id}`)} + onDelete={() => handleDeleteWiki(wiki.id, wiki.name)} + onEdit={() => navigate(`/boards/${id}/wikis/${wiki.id}/edit`)} + /> + ))} +
    + )} +
    +
    + ); +} + +interface WikiCardProps { + wiki: Wiki; + onClick: () => void; + onDelete: () => void; + onEdit: () => void; +} + +function WikiCard({ wiki, onClick, onDelete, onEdit }: WikiCardProps) { + return ( +
    +
    + {/* Header with actions */} +
    +

    {wiki.name}

    +
    + + +
    +
    + + {/* Summary */} + {wiki.summary &&

    {wiki.summary}

    } + + {/* Rich text content preview */} + {wiki.content && Array.isArray(wiki.content) && wiki.content.length > 0 && ( +
    + + {/* Fade overlay at bottom */} +
    +
    + )} + + {/* Footer with metadata */} +
    +
    + {wiki.category && ( + {wiki.category} + )} + {wiki.tags && wiki.tags.length > 0 && {wiki.tags.length} tag(s)} +
    + {wiki.updated_at && {new Date(wiki.updated_at).toLocaleDateString()}} +
    +
    +
    + ); +} + +export default BoardWikis; diff --git a/frontend/src/pages/CardDetail.tsx b/frontend/src/pages/CardDetail.tsx index 07ac0bc..2f07c02 100644 --- a/frontend/src/pages/CardDetail.tsx +++ b/frontend/src/pages/CardDetail.tsx @@ -9,6 +9,7 @@ import { CardSidebar } from '../components/CardSidebar'; import { CardComments } from '../components/CardComments'; import { CardChecklists } from '../components/CardChecklists'; import { CardLabels } from '../components/CardLabels'; +import { CardEpics } from '../components/CardEpics'; import { CardAttachments } from '../components/CardAttachments'; import { EditCardModal } from '../components/EditCardModal'; import { DeleteCardModal } from '../components/DeleteCardModal'; @@ -154,6 +155,14 @@ export function CardDetail() { refetchCard={fetchCard} /> + {/* Epic Section */} + + {/* Checklists Section */} (); + const navigate = useNavigate(); + const { createEpic } = useEpics(boardId || '0'); + const { board } = useBoard(parseInt(boardId || '0', 10)); + + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const [color, setColor] = useState(COLOR_PRESETS[5]); // Default to blue + const [content, setContent] = useState([{ children: [{ text: '' }] }]); + const [parentEpicId, setParentEpicId] = useState(undefined); + const [completedListId, setCompletedListId] = useState(undefined); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!name.trim()) { + return; + } + + const epicData: CreateEpicRequest = { + name: name.trim(), + description: description.trim() || undefined, + content: + content.length > 1 || (content[0] as any).children[0].text !== '' + ? (content as unknown as Record) + : undefined, + color, + parent_epic_id: parentEpicId, + completed_list_id: completedListId, + }; + + try { + const newEpic = await createEpic(epicData); + navigate(`/boards/${boardId}/epics/${newEpic.id}`); + } catch { + // Error is handled by hook + } + }; + + return ( +
    + +
    + + ← Back to Epics + +

    Create Epic

    +

    Create a new epic for this board

    +
    +
    + + +
    + {/* Name */} +
    + + setName(e.target.value)} + className="w-full bg-gray-700 border border-gray-600 rounded-lg px-4 py-2 text-white focus:outline-none focus:border-blue-500" + placeholder="Enter epic name..." + required + /> +
    + + {/* Description */} +
    + +