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..2ccef83 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""" diff --git a/backend/app/decorators/__init__.py b/backend/app/decorators/__init__.py new file mode 100644 index 0000000..dee151f --- /dev/null +++ b/backend/app/decorators/__init__.py @@ -0,0 +1,21 @@ +from app.decorators.owned import (load_board_owned, load_card_owned, + load_check_item_owned, load_checklist_owned, + load_comment_owned, load_file_owned, + load_list_owned) + +from .decorators import get_current_user_id, load_file_accessible_by_uuid +from .schema_validations import serialize_list_response, serialize_response + +__all__ = [ + "load_board_owned", + "load_card_owned", + "load_list_owned", + "load_checklist_owned", + "load_check_item_owned", + "load_comment_owned", + "load_file_owned", + "get_current_user_id", + "load_file_accessible_by_uuid", + "serialize_response", + "serialize_list_response", +] diff --git a/backend/app/decorators.py b/backend/app/decorators/decorators.py similarity index 52% rename from backend/app/decorators.py rename to backend/app/decorators/decorators.py index 1cdc9ee..75a4e6a 100644 --- a/backend/app/decorators.py +++ b/backend/app/decorators/decorators.py @@ -3,8 +3,7 @@ from functools import wraps from flask import abort, g from flask_jwt_extended import get_jwt_identity -from app.models import (Board, Card, CheckItem, Checklist, Comment, - FileAttachment, List) +from app.models import Board, Card, Comment, FileAttachment def get_current_user_id(): @@ -15,176 +14,6 @@ def get_current_user_id(): return g.jwt_identity -def load_board_owned(f): - """ - Loads a Board and ensures it belongs to the current user. - Injects 'board' into the route kwargs. - Aborts with 404 if not found or not owned. - """ - - @wraps(f) - def decorated_function(*args, **kwargs): - user_id = get_current_user_id() - board_id = kwargs.get("board_id") - - # SECURE QUERY: Filter by ID *and* User ID in the DB - board = Board.query.filter_by(id=board_id, user_id=user_id).first() - - if not board: - abort(404) - - kwargs["board"] = board - return f(*args, **kwargs) - - return decorated_function - - -def load_card_owned(f): - """ - Loads a Card and ensures its Parent Board belongs to the current user. - Injects 'card' into the route kwargs. - """ - - @wraps(f) - def decorated_function(*args, **kwargs): - user_id = get_current_user_id() - card_id = kwargs.get("card_id") - - # Join Board to check ownership securely in one query - card = ( - Card.query.join(Board) - .filter(Card.id == card_id, Board.user_id == user_id) - .first() - ) - - if not card: - abort(404) - - kwargs["card"] = card - return f(*args, **kwargs) - - return decorated_function - - -def load_list_owned(f): - """Loads a List ensuring Parent Board ownership.""" - - @wraps(f) - def decorated_function(*args, **kwargs): - user_id = get_current_user_id() - list_id = kwargs.get("list_id") - - lst = ( - List.query.join(Board) - .filter(List.id == list_id, Board.user_id == user_id) - .first() - ) - - if not lst: - abort(404) - - kwargs["lst"] = lst - return f(*args, **kwargs) - - return decorated_function - - -def load_checklist_owned(f): - """Loads a Checklist ensuring Parent Board ownership.""" - - @wraps(f) - def decorated_function(*args, **kwargs): - user_id = get_current_user_id() - checklist_id = kwargs.get("checklist_id") - - checklist = ( - Checklist.query.join(Card) - .join(Board) - .filter(Checklist.id == checklist_id, Board.user_id == user_id) - .first() - ) - - if not checklist: - abort(404) - - kwargs["checklist"] = checklist - return f(*args, **kwargs) - - return decorated_function - - -def load_check_item_owned(f): - """Loads a CheckItem ensuring Parent Board ownership.""" - - @wraps(f) - def decorated_function(*args, **kwargs): - user_id = get_current_user_id() - item_id = kwargs.get("item_id") - - check_item = ( - CheckItem.query.join(Checklist) - .join(Card) - .join(Board) - .filter(CheckItem.id == item_id, Board.user_id == user_id) - .first() - ) - - if not check_item: - abort(404) - - kwargs["check_item"] = check_item - return f(*args, **kwargs) - - return decorated_function - - -def load_comment_owned(f): - """ - Loads a Comment ensuring the Comment itself belongs to the user. - (Based on schema where Comment has user_id) - """ - - @wraps(f) - def decorated_function(*args, **kwargs): - user_id = get_current_user_id() - comment_id = kwargs.get("comment_id") - - comment = Comment.query.filter_by(id=comment_id, user_id=user_id).first() - - if not comment: - abort(404) - - kwargs["comment"] = comment - return f(*args, **kwargs) - - return decorated_function - - -def load_file_owned(f): - """ - Loads a FileAttachment ensuring it belongs to the user. - Aborts with 404 if not found or not owned. - """ - - @wraps(f) - def decorated_function(*args, **kwargs): - user_id = get_current_user_id() - file_id = kwargs.get("file_id") - - # Filter by ID and user ID - attachment = FileAttachment.query.filter_by( - id=file_id, uploaded_by=user_id - ).first() - - if not attachment: - abort(404) - - kwargs["file"] = attachment - return f(*args, **kwargs) - - return decorated_function - - def load_file_accessible(f): """ Loads a FileAttachment ensuring the user can access it. diff --git a/backend/app/decorators/owned.py b/backend/app/decorators/owned.py new file mode 100644 index 0000000..90f17d8 --- /dev/null +++ b/backend/app/decorators/owned.py @@ -0,0 +1,177 @@ +from functools import wraps + +from flask import abort + +from app.decorators.decorators import get_current_user_id +from app.models import (Board, Card, CheckItem, Checklist, Comment, + FileAttachment, List) + + +def load_board_owned(f): + """ + Loads a Board and ensures it belongs to the current user. + Injects 'board' into the route kwargs. + Aborts with 404 if not found or not owned. + """ + + @wraps(f) + def decorated_function(*args, **kwargs): + user_id = get_current_user_id() + board_id = kwargs.get("board_id") + + # SECURE QUERY: Filter by ID *and* User ID in the DB + board = Board.query.filter_by(id=board_id, user_id=user_id).first() + + if not board: + abort(404) + + kwargs["board"] = board + return f(*args, **kwargs) + + return decorated_function + + +def load_card_owned(f): + """ + Loads a Card and ensures its Parent Board belongs to the current user. + Injects 'card' into the route kwargs. + """ + + @wraps(f) + def decorated_function(*args, **kwargs): + user_id = get_current_user_id() + card_id = kwargs.get("card_id") + + # Join Board to check ownership securely in one query + card = ( + Card.query.join(Board) + .filter(Card.id == card_id, Board.user_id == user_id) + .first() + ) + + if not card: + abort(404) + + kwargs["card"] = card + return f(*args, **kwargs) + + return decorated_function + + +def load_list_owned(f): + """Loads a List ensuring Parent Board ownership.""" + + @wraps(f) + def decorated_function(*args, **kwargs): + user_id = get_current_user_id() + list_id = kwargs.get("list_id") + + lst = ( + List.query.join(Board) + .filter(List.id == list_id, Board.user_id == user_id) + .first() + ) + + if not lst: + abort(404) + + kwargs["lst"] = lst + return f(*args, **kwargs) + + return decorated_function + + +def load_checklist_owned(f): + """Loads a Checklist ensuring Parent Board ownership.""" + + @wraps(f) + def decorated_function(*args, **kwargs): + user_id = get_current_user_id() + checklist_id = kwargs.get("checklist_id") + + checklist = ( + Checklist.query.join(Card) + .join(Board) + .filter(Checklist.id == checklist_id, Board.user_id == user_id) + .first() + ) + + if not checklist: + abort(404) + + kwargs["checklist"] = checklist + return f(*args, **kwargs) + + return decorated_function + + +def load_check_item_owned(f): + """Loads a CheckItem ensuring Parent Board ownership.""" + + @wraps(f) + def decorated_function(*args, **kwargs): + user_id = get_current_user_id() + item_id = kwargs.get("item_id") + + check_item = ( + CheckItem.query.join(Checklist) + .join(Card) + .join(Board) + .filter(CheckItem.id == item_id, Board.user_id == user_id) + .first() + ) + + if not check_item: + abort(404) + + kwargs["check_item"] = check_item + return f(*args, **kwargs) + + return decorated_function + + +def load_comment_owned(f): + """ + Loads a Comment ensuring the Comment itself belongs to the user. + (Based on schema where Comment has user_id) + """ + + @wraps(f) + def decorated_function(*args, **kwargs): + user_id = get_current_user_id() + comment_id = kwargs.get("comment_id") + + comment = Comment.query.filter_by(id=comment_id, user_id=user_id).first() + + if not comment: + abort(404) + + kwargs["comment"] = comment + return f(*args, **kwargs) + + return decorated_function + + +def load_file_owned(f): + """ + Loads a FileAttachment ensuring it belongs to the user. + Aborts with 404 if not found or not owned. + """ + + @wraps(f) + def decorated_function(*args, **kwargs): + user_id = get_current_user_id() + file_id = kwargs.get("file_id") + + # Filter by ID and user ID + attachment = FileAttachment.query.filter_by( + id=file_id, uploaded_by=user_id + ).first() + + if not attachment: + abort(404) + + kwargs["file"] = attachment + return f(*args, **kwargs) + + return decorated_function diff --git a/backend/app/decorators/schema_validations.py b/backend/app/decorators/schema_validations.py new file mode 100644 index 0000000..1612fb9 --- /dev/null +++ b/backend/app/decorators/schema_validations.py @@ -0,0 +1,38 @@ +from functools import wraps + +from flask import jsonify + + +def serialize_response(model): + """Decorator to serialize single entity response""" + + def decorator(f): + @wraps(f) + def wrapper(*args, **kwargs): + result, status = f(*args, **kwargs) + if status not in [200, 201]: + return result, status + return jsonify(model.model_validate(result).model_dump()), status + + return wrapper + + return decorator + + +def serialize_list_response(model): + """Decorator to serialize list response""" + + def decorator(f): + @wraps(f) + def wrapper(*args, **kwargs): + results, status = f(*args, **kwargs) + if status not in [200, 201]: + return results, status + return ( + jsonify([model.model_validate(item).model_dump() for item in results]), + status, + ) + + return wrapper + + return decorator diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 215755d..d1fce73 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -4,18 +4,15 @@ 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", - "Product", - "Order", - "OrderItem", "Board", "List", "Card", @@ -25,4 +22,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/order.py b/backend/app/models/order.py deleted file mode 100644 index dd690e4..0000000 --- a/backend/app/models/order.py +++ /dev/null @@ -1,75 +0,0 @@ -from datetime import UTC, datetime - -from app import db - - -class Order(db.Model): - """Order model""" - - __tablename__ = "orders" - - id = db.Column(db.Integer, primary_key=True) - user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) - status = db.Column(db.String(20), default="pending", index=True) - total_amount = db.Column(db.Numeric(10, 2), nullable=False) - shipping_address = db.Column(db.Text) - 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), - ) - - # Relationships - user = db.relationship("User", back_populates="orders") - items = db.relationship( - "OrderItem", - back_populates="order", - lazy="dynamic", - cascade="all, delete-orphan", - ) - - def to_dict(self): - """Convert order to dictionary""" - return { - "id": self.id, - "user_id": self.user_id, - "status": self.status, - "total_amount": float(self.total_amount) if self.total_amount else None, - "shipping_address": self.shipping_address, - "created_at": self.created_at.isoformat() if self.created_at else None, - "updated_at": self.updated_at.isoformat() if self.updated_at else None, - "items": [item.to_dict() for item in self.items], - } - - def __repr__(self): - return f"" - - -class OrderItem(db.Model): - """Order Item model""" - - __tablename__ = "order_items" - - id = db.Column(db.Integer, primary_key=True) - order_id = db.Column(db.Integer, db.ForeignKey("orders.id"), nullable=False) - product_id = db.Column(db.Integer, db.ForeignKey("products.id"), nullable=False) - quantity = db.Column(db.Integer, nullable=False) - price = db.Column(db.Numeric(10, 2), nullable=False) - - # Relationships - order = db.relationship("Order", back_populates="items") - product = db.relationship("Product", back_populates="order_items") - - def to_dict(self): - """Convert order item to dictionary""" - return { - "id": self.id, - "order_id": self.order_id, - "product_id": self.product_id, - "quantity": self.quantity, - "price": float(self.price) if self.price else None, - } - - def __repr__(self): - return f"" diff --git a/backend/app/models/product.py b/backend/app/models/product.py deleted file mode 100644 index 94c6303..0000000 --- a/backend/app/models/product.py +++ /dev/null @@ -1,43 +0,0 @@ -from datetime import UTC, datetime - -from app import db - - -class Product(db.Model): - """Product model""" - - __tablename__ = "products" - - id = db.Column(db.Integer, primary_key=True) - name = db.Column(db.String(200), nullable=False, index=True) - description = db.Column(db.Text) - price = db.Column(db.Numeric(10, 2), nullable=False) - stock = db.Column(db.Integer, default=0) - image_url = db.Column(db.String(500)) - is_active = db.Column(db.Boolean, default=True) - 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), - ) - - # Relationships - order_items = db.relationship("OrderItem", back_populates="product", lazy="dynamic") - - def to_dict(self): - """Convert product to dictionary""" - return { - "id": self.id, - "name": self.name, - "description": self.description, - "price": float(self.price) if self.price else None, - "stock": self.stock, - "image_url": self.image_url, - "is_active": self.is_active, - "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/models/user.py b/backend/app/models/user.py index bcb7e06..cb32c79 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -26,7 +26,6 @@ class User(db.Model): ) # Relationships - orders = db.relationship("Order", back_populates="user", lazy="dynamic") boards = db.relationship( "Board", backref="user", cascade="all, delete-orphan", lazy="dynamic" ) 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..1ea428a 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__) @@ -79,333 +76,3 @@ def get_current_user(): return jsonify({"error": "User not found"}), 404 return jsonify(user.to_dict()), 200 - - -# Product Routes -@api_bp.route("/products", methods=["GET"]) -def get_products(): - """Get all products""" - - # time.sleep(5) # This adds a 5 second delay - - products = Product.query.filter_by(is_active=True).all() - - 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=["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 - - 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, - ) - - 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() - - 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..de9e5fb 100644 --- a/backend/app/routes/kanban/boards.py +++ b/backend/app/routes/kanban/boards.py @@ -5,7 +5,8 @@ from flask_jwt_extended import jwt_required from flask_pydantic import validate from app import db -from app.decorators import get_current_user_id, load_board_owned +from app.decorators import load_board_owned +from app.decorators.decorators import get_current_user_id from app.models import Board, Card, CardLabel, Label, List from app.schemas import (BoardCreateRequest, BoardResponse, BoardWithDetailsResponse) @@ -64,6 +65,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..d328c52 --- /dev/null +++ b/backend/app/routes/kanban/epics.py @@ -0,0 +1,170 @@ +"""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.decorators import serialize_list_response, serialize_response +from app.models import Board, Card, Epic +from app.schemas import (CardResponse, EpicCreateRequest, EpicResponse, + EpicUpdateRequest) + +from . import kanban_bp + + +@kanban_bp.route("/boards//epics", methods=["GET"]) +@jwt_required() +@serialize_list_response(EpicResponse) +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 epics, 200 + + +@kanban_bp.route("/boards//epics", methods=["POST"]) +@jwt_required() +@validate(body=EpicCreateRequest) +@serialize_response(EpicResponse) +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 epic, 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) +@serialize_response(EpicResponse) +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 epic, 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() +@serialize_response(CardResponse) +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, 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..78373d9 --- /dev/null +++ b/backend/app/routes/kanban/wikis.py @@ -0,0 +1,232 @@ +"""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.decorators import serialize_list_response, serialize_response +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() +@serialize_list_response(WikiResponse) +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 wikis, 200 + + +@kanban_bp.route("/boards//wikis", methods=["POST"]) +@jwt_required() +@validate(body=WikiCreateRequest) +@serialize_response(WikiResponse) +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 wiki, 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) +@serialize_response(WikiResponse) +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 wiki, 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..3a49d8a 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -7,18 +7,19 @@ 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, ValidationError) 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 - "ProductCreateRequest", - "ProductResponse", # Board schemas "BoardCreateRequest", "BoardResponse", @@ -50,4 +51,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..81515b8 100644 --- a/backend/app/schemas/card.py +++ b/backend/app/schemas/card.py @@ -47,6 +47,7 @@ class CardResponse(BaseModel): "pos": 65535.0, "list_id": 1, "board_id": 1, + "epic_id": 1, "due": "2024-12-31T23:59:59", "due_complete": False, "badges": {"votes": 0}, @@ -64,6 +65,7 @@ class CardResponse(BaseModel): pos: float list_id: int board_id: int + epic_id: Optional[int] due: Optional[datetime] = None due_complete: bool badges: Optional[Dict[str, Any]] = None @@ -115,3 +117,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/product.py b/backend/app/schemas/product.py deleted file mode 100644 index 7d9d501..0000000 --- a/backend/app/schemas/product.py +++ /dev/null @@ -1,71 +0,0 @@ -"""Pydantic schemas for Product model""" -from datetime import datetime -from decimal import Decimal -from typing import Optional - -from pydantic import BaseModel, ConfigDict, Field, field_validator - - -class ProductCreateRequest(BaseModel): - """Schema for creating a new product""" - - model_config = ConfigDict( - json_schema_extra={ - "example": { - "name": "Handcrafted Wooden Bowl", - "description": "A beautiful handcrafted bowl made from oak", - "price": 45.99, - "stock": 10, - "image_url": "https://example.com/bowl.jpg", - } - } - ) - - name: str = Field(..., min_length=1, max_length=200, description="Product name") - description: Optional[str] = Field(None, description="Product description") - price: Decimal = Field( - ..., gt=0, description="Product price (must be greater than 0)" - ) - stock: int = Field(default=0, ge=0, description="Product stock quantity") - image_url: Optional[str] = Field( - None, max_length=500, description="Product image URL" - ) - - @field_validator("price") - @classmethod - def validate_price(cls, v: Decimal) -> Decimal: - """Validate that price has at most 2 decimal places""" - if v.as_tuple().exponent < -2: - raise ValueError("Price must have at most 2 decimal places") - return v - - -class ProductResponse(BaseModel): - """Schema for product response""" - - model_config = ConfigDict( - from_attributes=True, - json_schema_extra={ - "example": { - "id": 1, - "name": "Handcrafted Wooden Bowl", - "description": "A beautiful handcrafted bowl made from oak", - "price": 45.99, - "stock": 10, - "image_url": "https://example.com/bowl.jpg", - "is_active": True, - "created_at": "2024-01-15T10:30:00", - "updated_at": "2024-01-15T10:30:00", - } - }, - ) - - id: int - name: str - description: Optional[str] = None - price: float - stock: int - image_url: Optional[str] = None - is_active: bool - created_at: Optional[datetime] = None - updated_at: Optional[datetime] = None 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/migrations/versions/a9709e7ed22d_remove_products_tables.py b/backend/migrations/versions/a9709e7ed22d_remove_products_tables.py new file mode 100644 index 0000000..7224353 --- /dev/null +++ b/backend/migrations/versions/a9709e7ed22d_remove_products_tables.py @@ -0,0 +1,74 @@ +"""remove products tables + +Revision ID: a9709e7ed22d +Revises: 84551733ccb0 +Create Date: 2026-04-12 12:55:32.064593 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = 'a9709e7ed22d' +down_revision = '84551733ccb0' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('products', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_products_name')) + + op.drop_table('products') + op.drop_table('order_items') + with op.batch_alter_table('orders', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_orders_status')) + + op.drop_table('orders') + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('orders', + sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False), + sa.Column('user_id', sa.INTEGER(), autoincrement=False, nullable=False), + sa.Column('status', sa.VARCHAR(length=20), autoincrement=False, nullable=True), + sa.Column('total_amount', sa.NUMERIC(precision=10, scale=2), autoincrement=False, nullable=False), + sa.Column('shipping_address', sa.TEXT(), autoincrement=False, nullable=True), + sa.Column('created_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=True), + sa.Column('updated_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], name=op.f('orders_user_id_fkey')), + sa.PrimaryKeyConstraint('id', name=op.f('orders_pkey')) + ) + with op.batch_alter_table('orders', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_orders_status'), ['status'], unique=False) + + op.create_table('order_items', + sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False), + sa.Column('order_id', sa.INTEGER(), autoincrement=False, nullable=False), + sa.Column('product_id', sa.INTEGER(), autoincrement=False, nullable=False), + sa.Column('quantity', sa.INTEGER(), autoincrement=False, nullable=False), + sa.Column('price', sa.NUMERIC(precision=10, scale=2), autoincrement=False, nullable=False), + sa.ForeignKeyConstraint(['order_id'], ['orders.id'], name=op.f('order_items_order_id_fkey')), + sa.ForeignKeyConstraint(['product_id'], ['products.id'], name=op.f('order_items_product_id_fkey')), + sa.PrimaryKeyConstraint('id', name=op.f('order_items_pkey')) + ) + op.create_table('products', + sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False), + sa.Column('name', sa.VARCHAR(length=200), autoincrement=False, nullable=False), + sa.Column('description', sa.TEXT(), autoincrement=False, nullable=True), + sa.Column('price', sa.NUMERIC(precision=10, scale=2), autoincrement=False, nullable=False), + sa.Column('stock', sa.INTEGER(), autoincrement=False, nullable=True), + sa.Column('image_url', sa.VARCHAR(length=500), autoincrement=False, nullable=True), + sa.Column('is_active', sa.BOOLEAN(), autoincrement=False, nullable=True), + sa.Column('created_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=True), + sa.Column('updated_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=True), + sa.PrimaryKeyConstraint('id', name=op.f('products_pkey')) + ) + with op.batch_alter_table('products', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_products_name'), ['name'], unique=False) + + # ### end Alembic commands ### diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 67d42d1..d2e0b7d 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -1,5 +1,6 @@ """Pytest configuration and fixtures""" import logging +import time from io import BytesIO import pytest @@ -7,8 +8,7 @@ from faker import Faker from PIL import Image from app import create_app, db -from app.models import (Board, Card, Comment, List, Order, OrderItem, Product, - User) +from app.models import Board, Card, Comment, List, User log = logging.getLogger(__name__) fake = Faker() @@ -116,7 +116,7 @@ def regular_user(db_session): """Create a regular user for testing""" user = User( email=fake.email(), - username=fake.user_name(), + username=f"{fake.user_name()}_{int(time.time() * 1000)}", first_name=fake.first_name(), last_name=fake.last_name(), is_admin=False, @@ -145,39 +145,6 @@ def inactive_user(db_session): return user -@pytest.fixture -def product(db_session): - """Create a product for testing""" - product = Product( - name=fake.sentence(nb_words=4)[:-1], # Remove period - description=fake.paragraph(), - price=fake.pydecimal(left_digits=2, right_digits=2, positive=True), - stock=fake.pyint(min_value=0, max_value=100), - image_url=fake.url(), - ) - db_session.add(product) - db_session.commit() - return product - - -@pytest.fixture -def products(db_session): - """Create multiple products for testing""" - products = [] - for _ in range(5): - product = Product( - name=fake.sentence(nb_words=4)[:-1], - description=fake.paragraph(), - price=fake.pydecimal(left_digits=2, right_digits=2, positive=True), - stock=fake.pyint(min_value=20, max_value=100), - image_url=fake.url(), - ) - db_session.add(product) - products.append(product) - db_session.commit() - return products - - @pytest.fixture def auth_headers(client, regular_user): """Get authentication headers for a regular user""" @@ -203,33 +170,6 @@ def admin_headers(client, admin_user): return {"Authorization": f"Bearer {token}"} -@pytest.fixture -def order(db_session, regular_user, products): - print("-----order-created------") - """Create an order for testing""" - order = Order( - user_id=regular_user.id, total_amount=0.0, shipping_address=fake.address() - ) - db_session.add(order) - db_session.flush() - - total_amount = 0 - for i, product in enumerate(products[:2]): - quantity = fake.pyint(min_value=1, max_value=5) - order_item = OrderItem( - order_id=order.id, - product_id=product.id, - quantity=quantity, - price=product.price, - ) - total_amount += float(product.price) * quantity - db_session.add(order_item) - - order.total_amount = total_amount - db_session.commit() - return order - - # ============ File Upload Fixtures ============ 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_models.py b/backend/tests/test_models.py index aff1e86..db1af40 100644 --- a/backend/tests/test_models.py +++ b/backend/tests/test_models.py @@ -1,9 +1,8 @@ """Test models""" -from decimal import Decimal import pytest -from app.models import Order, OrderItem, Product, User +from app.models import User class TestUserModel: @@ -69,132 +68,3 @@ class TestUserModel: db_session.commit() assert repr(user) == "" - - -class TestProductModel: - """Test Product model""" - - @pytest.mark.unit - def test_product_creation(self, db_session): - """Test creating a product""" - product = Product( - name="Test Product", - description="A test product", - price=Decimal("99.99"), - stock=10, - image_url="https://example.com/product.jpg", - ) - db_session.add(product) - db_session.commit() - - assert product.id is not None - assert product.name == "Test Product" - assert product.price == Decimal("99.99") - assert product.stock == 10 - assert product.is_active is True - - @pytest.mark.unit - def test_product_to_dict(self, db_session): - """Test product serialization to dictionary""" - product = Product( - name="Test Product", - description="A test product", - price=Decimal("99.99"), - stock=10, - ) - db_session.add(product) - db_session.commit() - - product_dict = product.to_dict() - assert product_dict["name"] == "Test Product" - assert product_dict["price"] == 99.99 - assert isinstance(product_dict["created_at"], str) - assert isinstance(product_dict["updated_at"], str) - - @pytest.mark.unit - def test_product_defaults(self, db_session): - """Test product default values""" - product = Product(name="Test Product", price=Decimal("9.99")) - db_session.add(product) - db_session.commit() - - assert product.stock == 0 - assert product.is_active is True - assert product.description is None - assert product.image_url is None - - @pytest.mark.unit - def test_product_repr(self, db_session): - """Test product string representation""" - product = Product(name="Test Product", price=Decimal("9.99")) - db_session.add(product) - db_session.commit() - - assert repr(product) == "" - - -class TestOrderModel: - """Test Order model""" - - @pytest.mark.unit - def test_order_creation(self, db_session, regular_user): - """Test creating an order""" - order = Order( - user_id=regular_user.id, - total_amount=Decimal("199.99"), - shipping_address="123 Test St", - ) - db_session.add(order) - db_session.commit() - - assert order.id is not None - assert order.user_id == regular_user.id - assert order.total_amount == Decimal("199.99") - - @pytest.mark.unit - def test_order_to_dict(self, db_session, regular_user): - """Test order serialization to dictionary""" - order = Order( - user_id=regular_user.id, - total_amount=Decimal("199.99"), - shipping_address="123 Test St", - ) - db_session.add(order) - db_session.commit() - - order_dict = order.to_dict() - assert order_dict["user_id"] == regular_user.id - assert order_dict["total_amount"] == 199.99 - assert isinstance(order_dict["created_at"], str) - - -class TestOrderItemModel: - """Test OrderItem model""" - - @pytest.mark.unit - def test_order_item_creation(self, db_session, order, product): - """Test creating an order item""" - order_item = OrderItem( - order_id=order.id, product_id=product.id, quantity=2, price=product.price - ) - db_session.add(order_item) - db_session.commit() - - assert order_item.id is not None - assert order_item.order_id == order.id - assert order_item.product_id == product.id - assert order_item.quantity == 2 - - @pytest.mark.unit - def test_order_item_to_dict(self, db_session, order, product): - """Test order item serialization to dictionary""" - order_item = OrderItem( - order_id=order.id, product_id=product.id, quantity=2, price=product.price - ) - db_session.add(order_item) - db_session.commit() - - item_dict = order_item.to_dict() - assert item_dict["order_id"] == order.id - assert item_dict["product_id"] == product.id - assert item_dict["quantity"] == 2 diff --git a/backend/tests/test_routes.py b/backend/tests/test_routes.py index 57388a5..bb89a19 100644 --- a/backend/tests/test_routes.py +++ b/backend/tests/test_routes.py @@ -112,234 +112,3 @@ class TestAuthRoutes: """Test getting current user without authentication""" response = client.get("/api/users/me") assert response.status_code == 401 - - -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 - - before_count = Product.query.count() - response = client.get("/api/products") - - 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 - - before_count = Product.query.count() - response = client.get("/api/products") - - 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}") - - 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_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 - - @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() - - @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 - - @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"] - - @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"] - - @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 - - @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 - - @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 - - -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) - - 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_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 - - @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}]}, - ) - - 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) - - print("test_get_single_order", response.get_json()) - - 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"}, - ) - - # Admin should be able to access any order - # This test assumes order exists, adjust as needed - pass diff --git a/backend/tests/test_schemas.py b/backend/tests/test_schemas.py deleted file mode 100644 index 0fa7a90..0000000 --- a/backend/tests/test_schemas.py +++ /dev/null @@ -1,249 +0,0 @@ -"""Test Pydantic schemas""" -from decimal import Decimal - -import pytest -from pydantic import ValidationError - -from app.schemas import ProductCreateRequest, ProductResponse - - -class TestProductCreateRequestSchema: - """Test ProductCreateRequest schema""" - - @pytest.mark.unit - def test_valid_product_request(self): - """Test valid product creation request""" - data = { - "name": "Handcrafted Wooden Bowl", - "description": "A beautiful handcrafted bowl", - "price": 45.99, - "stock": 10, - "image_url": "https://example.com/bowl.jpg", - } - - product = ProductCreateRequest(**data) - assert product.name == data["name"] - assert product.description == data["description"] - assert product.price == Decimal("45.99") - assert product.stock == 10 - assert product.image_url == data["image_url"] - - @pytest.mark.unit - def test_minimal_valid_request(self): - """Test minimal valid request (only required fields)""" - data = {"name": "Simple Product", "price": 19.99} - - product = ProductCreateRequest(**data) - assert product.name == "Simple Product" - assert product.price == Decimal("19.99") - assert product.stock == 0 - assert product.description is None - assert product.image_url is None - - @pytest.mark.unit - def test_missing_name(self): - """Test request with missing name""" - data = {"price": 19.99} - - with pytest.raises(ValidationError) as exc_info: - ProductCreateRequest(**data) - - errors = exc_info.value.errors() - assert any(error["loc"] == ("name",) for error in errors) - - @pytest.mark.unit - def test_missing_price(self): - """Test request with missing price""" - data = {"name": "Test Product"} - - with pytest.raises(ValidationError) as exc_info: - ProductCreateRequest(**data) - - errors = exc_info.value.errors() - assert any(error["loc"] == ("price",) for error in errors) - - @pytest.mark.unit - def test_invalid_price_negative(self): - """Test request with negative price""" - data = {"name": "Test Product", "price": -10.99} - - with pytest.raises(ValidationError) as exc_info: - ProductCreateRequest(**data) - - errors = exc_info.value.errors() - assert any(error["type"] == "greater_than" for error in errors) - - @pytest.mark.unit - def test_invalid_price_zero(self): - """Test request with zero price""" - data = {"name": "Test Product", "price": 0.0} - - with pytest.raises(ValidationError) as exc_info: - ProductCreateRequest(**data) - - errors = exc_info.value.errors() - assert any(error["type"] == "greater_than" for error in errors) - - @pytest.mark.unit - def test_invalid_price_too_many_decimals(self): - """Test request with too many decimal places""" - data = {"name": "Test Product", "price": 10.999} - - with pytest.raises(ValidationError) as exc_info: - ProductCreateRequest(**data) - - errors = exc_info.value.errors() - assert any("decimal places" in str(error).lower() for error in errors) - - @pytest.mark.unit - def test_invalid_stock_negative(self): - """Test request with negative stock""" - data = {"name": "Test Product", "price": 19.99, "stock": -5} - - with pytest.raises(ValidationError) as exc_info: - ProductCreateRequest(**data) - - errors = exc_info.value.errors() - assert any(error["type"] == "greater_than_equal" for error in errors) - - @pytest.mark.unit - def test_name_too_long(self): - """Test request with name exceeding max length""" - data = {"name": "A" * 201, "price": 19.99} # Exceeds 200 character limit - - with pytest.raises(ValidationError) as exc_info: - ProductCreateRequest(**data) - - errors = exc_info.value.errors() - assert any(error["loc"] == ("name",) for error in errors) - - @pytest.mark.unit - def test_image_url_too_long(self): - """Test request with image_url exceeding max length""" - data = { - "name": "Test Product", - "price": 19.99, - "image_url": "A" * 501, # Exceeds 500 character limit - } - - with pytest.raises(ValidationError) as exc_info: - ProductCreateRequest(**data) - - errors = exc_info.value.errors() - assert any(error["loc"] == ("image_url",) for error in errors) - - @pytest.mark.unit - def test_price_string_conversion(self): - """Test price string to Decimal conversion""" - data = {"name": "Test Product", "price": "29.99"} - - product = ProductCreateRequest(**data) - assert product.price == Decimal("29.99") - - @pytest.mark.unit - def test_stock_string_conversion(self): - """Test stock string to int conversion""" - data = {"name": "Test Product", "price": 19.99, "stock": "10"} - - product = ProductCreateRequest(**data) - assert product.stock == 10 - assert isinstance(product.stock, int) - - -class TestProductResponseSchema: - """Test ProductResponse schema""" - - @pytest.mark.unit - def test_valid_product_response(self): - """Test valid product response""" - data = { - "id": 1, - "name": "Test Product", - "description": "A test product", - "price": 45.99, - "stock": 10, - "image_url": "https://example.com/product.jpg", - "is_active": True, - "created_at": "2024-01-15T10:30:00", - "updated_at": "2024-01-15T10:30:00", - } - - product = ProductResponse(**data) - assert product.id == 1 - assert product.name == "Test Product" - assert product.price == 45.99 - assert product.stock == 10 - assert product.is_active is True - - @pytest.mark.unit - def test_product_response_with_none_fields(self): - """Test product response with optional None fields""" - data = { - "id": 1, - "name": "Test Product", - "price": 19.99, - "stock": 0, - "is_active": True, - } - - product = ProductResponse(**data) - assert product.description is None - assert product.image_url is None - assert product.created_at is None - assert product.updated_at is None - - @pytest.mark.unit - def test_model_validate_from_sqlalchemy(self, db_session): - """Test validating SQLAlchemy model to Pydantic schema""" - from app.models import Product - - db_product = Product( - name="Test Product", - description="A test product", - price=Decimal("45.99"), - stock=10, - ) - db_session.add(db_product) - db_session.commit() - - # Validate using model_validate (for SQLAlchemy models) - response = ProductResponse.model_validate(db_product) - assert response.name == "Test Product" - assert response.price == 45.99 - assert response.stock == 10 - - @pytest.mark.unit - def test_model_dump(self): - """Test model_dump method""" - data = { - "id": 1, - "name": "Test Product", - "price": 19.99, - "stock": 5, - "is_active": True, - } - - product = ProductResponse(**data) - dumped = product.model_dump() - - assert isinstance(dumped, dict) - assert dumped["id"] == 1 - assert dumped["name"] == "Test Product" - assert dumped["price"] == 19.99 - - @pytest.mark.unit - def test_model_dump_json(self): - """Test model_dump_json method""" - data = { - "id": 1, - "name": "Test Product", - "price": 19.99, - "stock": 5, - "is_active": True, - } - - product = ProductResponse(**data) - json_str = product.model_dump_json() - - assert isinstance(json_str, str) - assert "Test Product" in json_str 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..1cbca5b 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..8b6c90c 100644 --- a/frontend/src/hooks/useApi.ts +++ b/frontend/src/hooks/useApi.ts @@ -1,5 +1,5 @@ import axios from 'axios'; -import { RegisterData, UserData, ProductData, OrderData, AuthResponse } from '../types'; +import { RegisterData, UserData, AuthResponse } from '../types'; import { Board, BoardWithDetails, @@ -69,44 +69,6 @@ export function useApi() { return response.data; }, - // Products - getProducts: async (): Promise => { - const response = await api.get('/products'); - return response.data; - }, - getProduct: async (id: string): Promise => { - const response = await api.get(`/products/${id}`); - return response.data; - }, - createProduct: async (productData: Omit): Promise => { - const response = await api.post('/products', productData); - return response.data; - }, - updateProduct: async ( - id: string, - productData: Partial - ): Promise => { - const response = await api.put(`/products/${id}`, productData); - return response.data; - }, - deleteProduct: async (id: string): Promise => { - await api.delete(`/products/${id}`); - }, - - // Orders - getOrders: async (): Promise => { - const response = await api.get('/orders'); - return response.data; - }, - getOrder: async (id: string): Promise => { - const response = await api.get(`/orders/${id}`); - return response.data; - }, - createOrder: async (orderData: Omit): Promise => { - const response = await api.post('/orders', orderData); - return response.data; - }, - // Boards getBoards: async (): Promise => { const response = await api.get('/boards'); @@ -299,6 +261,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/useProducts.ts b/frontend/src/hooks/useProducts.ts deleted file mode 100644 index f4da397..0000000 --- a/frontend/src/hooks/useProducts.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { useState, useEffect } from 'react'; -import { useApi } from './useApi'; -import { useLoader } from '../context/loaders/useLoader'; -import { useToast } from '../context/toasts/useToast'; -import { ProductData } from '../types'; - -export function useProducts() { - const [products, setProducts] = useState([]); - const [error, setError] = useState(null); - - const { getProducts } = useApi(); - const { withLoader } = useLoader(); - const { addNotification } = useToast(); - - const fetchProducts = async () => { - try { - setError(null); - - // Use withLoader to show loading state and handle errors - const data = await withLoader(() => getProducts(), 'Loading products...'); - - setProducts(data); - - // // Show success toast - // addNotification({ - // type: 'success', - // title: 'Products Loaded', - // message: `Successfully loaded ${data.length} products.`, - // duration: 3000, - // }); - - return data; - } catch (err) { - const errorMessage = err instanceof Error ? err.message : 'Failed to load products'; - setError(errorMessage); - - // Show error toast - addNotification({ - type: 'error', - title: 'Error Loading Products', - message: errorMessage, - duration: 5000, - }); - - return []; - } - }; - - // Optionally auto-fetch on mount - useEffect(() => { - fetchProducts(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - return { - products, - error, - loading: false, // Loading is handled by the global loader - refetch: fetchProducts, - }; -} 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 */} +
    + +