Compare commits

..

10 commits

121 changed files with 11242 additions and 1724 deletions

View file

@ -17,6 +17,16 @@ GRAFANA_PASSWORD=change-this-password-in-production
CELERY_BROKER_URL=redis://redis:6379/0
CELERY_RESULT_BACKEND=redis://redis:6379/0
# MinIO Configuration (Object Storage)
# MinIO server stays hidden - Flask proxies all requests
# MINIO_ENDPOINT: Internal Docker network address (for server-to-server communication)
MINIO_ENDPOINT=minio:9000
MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin
MINIO_USE_SSL=false
# Note: Thumbnails are served via /api/files/:id/proxy-thumbnail (no auth)
# Full files require JWT authentication via /api/files/:id/download
# Optional: External Services
# REDIS_URL=redis://localhost:6379/0
# SMTP_HOST=smtp.gmail.com

4
.gitignore vendored
View file

@ -83,4 +83,6 @@ htmlcov/
celerybeat-schedule
backend/app/static
backend/app/static
.clinerules

View file

@ -21,6 +21,10 @@ install: ## Install dependencies
@echo "Installing frontend dependencies..."
cd frontend && npm install
dev-backend-install:
@echo "Installing backend dependencies..."
. backend/venv/bin/activate && pip install -r backend/requirements/dev.txt
dev-services: ## Start development services (postgres & redis only)
@echo "Starting development services (postgres & redis)..."
docker compose -f docker-compose.dev.yml up -d
@ -62,6 +66,9 @@ restart: ## Restart all services
logs: ## Show logs from all services
docker compose logs -f
dev-logs: ## Show logs from all services
docker compose -f docker-compose.dev.yml logs
test: ## Run all tests
@echo "Running backend tests..."
cd backend && . venv/bin/activate && pytest
@ -129,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

View file

@ -9,3 +9,8 @@ TEST_DATABASE_URL=sqlite:///test.db
# Celery Configuration
CELERY_BROKER_URL=redis://localhost:6379/0
CELERY_RESULT_BACKEND=redis://localhost:6379/0
# Minio configuration
MINIO_ENDPOINT=localhost:9000
MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin

View file

@ -40,6 +40,13 @@ def create_app(config_name=None):
app, resources={r"/api/*": {"origins": app.config.get("CORS_ORIGINS", "*")}}
)
# Import storage extension
from app.services.storage.storage_extension import storage
if not (app.config.get("TESTING") or app.config.get("USE_MOCK_STORAGE")):
# Initialize storage extension (MinIO)
storage.init_app(app)
# Initialize Celery
from app.celery import init_celery

View file

@ -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"""
@ -46,11 +58,31 @@ class DevelopmentConfig(Config):
DEBUG = True
SQLALCHEMY_DATABASE_URI = os.environ["DATABASE_URL"]
# MinIO Configuration for Development
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_USE_SSL = os.environ.get("MINIO_USE_SSL", "false").lower() == "true"
MINIO_IMAGES_BUCKET = "kanban-images"
MINIO_DOCUMENTS_BUCKET = "kanban-documents"
MINIO_THUMBNAILS_BUCKET = "kanban-thumbnails"
MAX_UPLOAD_SIZE_IMAGE = 10 * 1024 * 1024
MAX_UPLOAD_SIZE_DOCUMENT = 50 * 1024 * 1024
ALLOWED_IMAGE_TYPES = [
"image/jpeg",
"image/png",
"image/gif",
"image/svg+xml",
"image/webp",
]
ALLOWED_DOCUMENT_TYPES = ["application/pdf"]
class TestingConfig(Config):
"""Testing configuration"""
TESTING = True
USE_MOCK_STORAGE = True # Use in-memory mock storage for tests
SQLALCHEMY_DATABASE_URI = os.environ.get("TEST_DATABASE_URL")
WTF_CSRF_ENABLED = False
@ -63,6 +95,31 @@ class TestingConfig(Config):
"pool_pre_ping": True, # Verify connections before using
}
# MinIO configuration (not used in tests due to USE_MOCK_STORAGE=True)
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_USE_SSL = os.environ.get("MINIO_USE_SSL", "false").lower() == "true"
# Bucket names (required for FileService even with mock storage)
MINIO_IMAGES_BUCKET = "kanban-images"
MINIO_DOCUMENTS_BUCKET = "kanban-documents"
MINIO_THUMBNAILS_BUCKET = "kanban-thumbnails"
# File upload limits
MAX_UPLOAD_SIZE_IMAGE = 10 * 1024 * 1024 # 10MB
MAX_UPLOAD_SIZE_DOCUMENT = 50 * 1024 * 1024 # 50MB
# Allowed file types
ALLOWED_IMAGE_TYPES = [
"image/jpeg",
"image/png",
"image/gif",
"image/svg+xml",
"image/webp",
]
ALLOWED_DOCUMENT_TYPES = ["application/pdf"]
class ProductionConfig(Config):
"""Production configuration"""
@ -75,6 +132,31 @@ class ProductionConfig(Config):
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = "Lax"
# 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_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"
# File Upload Limits (in bytes)
MAX_UPLOAD_SIZE_IMAGE = 10 * 1024 * 1024 # 10MB
MAX_UPLOAD_SIZE_DOCUMENT = 50 * 1024 * 1024 # 50MB
# Allowed File Types
ALLOWED_IMAGE_TYPES = [
"image/jpeg",
"image/png",
"image/gif",
"image/svg+xml",
"image/webp",
]
ALLOWED_DOCUMENT_TYPES = ["application/pdf"]
config_by_name = {
"dev": DevelopmentConfig,

View file

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

View file

@ -0,0 +1,152 @@
from functools import wraps
from flask import abort, g
from flask_jwt_extended import get_jwt_identity
from app.models import Board, Card, Comment, FileAttachment
def get_current_user_id():
"""Helper to consistently get user ID"""
# Cache in g if you want to avoid decoding JWT multiple times per request
if not hasattr(g, "jwt_identity"):
g.jwt_identity = int(get_jwt_identity())
return g.jwt_identity
def load_file_accessible(f):
"""
Loads a FileAttachment ensuring the user can access it.
User can access if:
1. They uploaded the file, OR
2. The file is attached to an entity they own (via Board ownership)
Aborts with 404 if not found or not accessible.
"""
@wraps(f)
def decorated_function(*args, **kwargs):
user_id = get_current_user_id()
file_id = kwargs.get("file_id")
# Try to find file uploaded by user
attachment = FileAttachment.query.filter_by(
id=file_id, uploaded_by=user_id
).first()
# If not found, check if attached to a Card that belongs to user's board
if not attachment:
# For Card attachments
card_attachment = (
FileAttachment.query.join(
Card,
(FileAttachment.attachable_type == "Card")
& (FileAttachment.attachable_id == Card.id),
)
.join(Board)
.filter(
FileAttachment.id == file_id,
Board.user_id == user_id,
)
.first()
)
if card_attachment:
attachment = card_attachment
# If still not found, check if attached
# to a Comment that belongs to user's board
if not attachment:
# For Comment attachments
comment_attachment = (
FileAttachment.query.join(
Comment,
(FileAttachment.attachable_type == "Comment")
& (FileAttachment.attachable_id == Comment.id),
)
.join(Card)
.join(Board)
.filter(
FileAttachment.id == file_id,
Board.user_id == user_id,
)
.first()
)
if comment_attachment:
attachment = comment_attachment
if not attachment:
abort(404)
kwargs["file"] = attachment
return f(*args, **kwargs)
return decorated_function
def load_file_accessible_by_uuid(f):
"""
Loads a FileAttachment by UUID ensuring the user can access it.
User can access if:
1. They uploaded the file, OR
2. The file is attached to an entity they own (via Board ownership)
Aborts with 404 if not found or not accessible.
"""
@wraps(f)
def decorated_function(*args, **kwargs):
user_id = get_current_user_id()
file_uuid = kwargs.get("file_uuid")
# Try to find file uploaded by user
attachment = FileAttachment.query.filter_by(
uuid=file_uuid, uploaded_by=user_id
).first()
# If not found, check if attached to a Card that belongs to user's board
if not attachment:
# For Card attachments
card_attachment = (
FileAttachment.query.join(
Card,
(FileAttachment.attachable_type == "Card")
& (FileAttachment.attachable_id == Card.id),
)
.join(Board)
.filter(
FileAttachment.uuid == file_uuid,
Board.user_id == user_id,
)
.first()
)
if card_attachment:
attachment = card_attachment
# If still not found, check if attached to a
# Comment that belongs to user's board
if not attachment:
# For Comment attachments
comment_attachment = (
FileAttachment.query.join(
Comment,
(FileAttachment.attachable_type == "Comment")
& (FileAttachment.attachable_id == Comment.id),
)
.join(Card)
.join(Board)
.filter(
FileAttachment.uuid == file_uuid,
Board.user_id == user_id,
)
.first()
)
if comment_attachment:
attachment = comment_attachment
if not attachment:
abort(404)
kwargs["file"] = attachment
return f(*args, **kwargs)
return decorated_function

View file

@ -1,17 +1,10 @@
from functools import wraps
from flask import abort, g
from flask_jwt_extended import get_jwt_identity
from flask import abort
from app.models import Board, Card, CheckItem, Checklist, Comment, List
def get_current_user_id():
"""Helper to consistently get user ID"""
# Cache in g if you want to avoid decoding JWT multiple times per request
if not hasattr(g, "jwt_identity"):
g.jwt_identity = int(get_jwt_identity())
return g.jwt_identity
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):
@ -157,3 +150,28 @@ def load_comment_owned(f):
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

View file

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

View file

@ -4,17 +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",
@ -23,4 +21,8 @@ __all__ = [
"Checklist",
"CheckItem",
"Comment",
"FileAttachment",
"Epic",
"Wiki",
"wiki_entity_links",
]

View file

@ -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)
@ -57,6 +60,14 @@ class Card(db.Model):
comments = db.relationship(
"Comment", backref="card", cascade="all, delete-orphan", lazy="dynamic"
)
attachments = db.relationship(
"FileAttachment",
foreign_keys="FileAttachment.attachable_id",
primaryjoin="""and_(FileAttachment.attachable_id == Card.id,
FileAttachment.attachable_type == 'Card')""",
cascade="all, delete-orphan",
lazy="dynamic",
)
def to_dict(self):
"""Convert card to dictionary"""
@ -71,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,
@ -83,3 +95,130 @@ class Card(db.Model):
def __repr__(self):
return f"<Card {self.name}>"
# 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)

View file

@ -30,6 +30,17 @@ class Comment(db.Model):
onupdate=lambda: datetime.now(UTC),
)
# Relationships
attachments = db.relationship(
"FileAttachment",
foreign_keys="FileAttachment.attachable_id",
primaryjoin="""and_(FileAttachment.attachable_id == Comment.id,
FileAttachment.attachable_type == 'Comment')""",
cascade="all, delete-orphan",
lazy="dynamic",
overlaps="attachments",
)
def to_dict(self):
"""Convert comment to dictionary"""
return {

View file

@ -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"<Epic {self.name}>"

View file

@ -0,0 +1,75 @@
import uuid
from datetime import UTC, datetime
from sqlalchemy import Index
from app import db
class FileAttachment(db.Model):
"""Polymorphic file attachment model for Cards, Comments, and other entities"""
__tablename__ = "file_attachments"
id = db.Column(db.Integer, primary_key=True)
uuid = db.Column(
db.String(36), nullable=False, unique=True, default=lambda: str(uuid.uuid4())
)
filename = db.Column(db.String(255), nullable=False)
original_name = db.Column(db.String(255), nullable=False)
file_type = db.Column(db.String(50), nullable=False) # 'image', 'pdf', 'document'
mime_type = db.Column(db.String(100), nullable=False)
file_size = db.Column(db.Integer, nullable=False)
# MinIO storage information
minio_bucket = db.Column(db.String(100), nullable=False)
minio_object_name = db.Column(db.String(255), nullable=False, unique=True)
# Thumbnail information (optional, for images)
thumbnail_minio_object_name = db.Column(db.String(255))
thumbnail_minio_bucket = db.Column(db.String(100))
# Polymorphic relationship - can attach to different entity types
attachable_type = db.Column(
db.String(50), nullable=False
) # 'Card', 'Comment', 'Epic'
attachable_id = db.Column(db.Integer, nullable=False) # ID of the attached entity
# Upload metadata
uploaded_by = db.Column(
db.Integer, db.ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(UTC))
# Relationships
uploader = db.relationship("User", backref="uploaded_files")
# Indexes for efficient queries
__table_args__ = (
Index("ix_file_attachments_attachable", "attachable_type", "attachable_id"),
Index("ix_file_attachments_user", "uploaded_by"),
Index("ix_file_attachments_uuid", "uuid"),
)
def to_dict(self):
"""Convert file attachment to dictionary"""
return {
"id": self.id,
"uuid": self.uuid,
"filename": self.filename,
"original_name": self.original_name,
"file_type": self.file_type,
"mime_type": self.mime_type,
"file_size": self.file_size,
"minio_bucket": self.minio_bucket,
"minio_object_name": self.minio_object_name,
"thumbnail_minio_object_name": self.thumbnail_minio_object_name,
"thumbnail_minio_bucket": self.thumbnail_minio_bucket,
"attachable_type": self.attachable_type,
"attachable_id": self.attachable_id,
"uploaded_by": self.uploaded_by,
"created_at": self.created_at.isoformat() if self.created_at else None,
}
def __repr__(self):
return f"<FileAttachment {self.original_name} ({self.file_type})>"

View file

@ -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"<Order {self.id}>"
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"<OrderItem {self.id}>"

View file

@ -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"<Product {self.name}>"

View file

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

111
backend/app/models/wiki.py Normal file
View file

@ -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"<Wiki {self.name}>"

View file

@ -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/<int:product_id>", 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/<int:product_id>", 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/<int:product_id>", 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/<int:order_id>", 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/<task_id>", 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

View file

@ -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, labels, # noqa: F401 E402
lists)
from . import (boards, cards, checklists, comments, epics, # noqa: F401 E402
files, labels, lists, wikis)
# fmt: on

View file

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

View file

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

View file

@ -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/<int:board_id>/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/<int:board_id>/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/<int:epic_id>", 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/<int:epic_id>", 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/<int:epic_id>", 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/<int:card_id>/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/<int:card_id>/epics/<int:epic_id>", 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

View file

@ -0,0 +1,299 @@
"""Routes for file upload and management"""
from io import BytesIO
from flask import Response, request
from flask_jwt_extended import jwt_required
from werkzeug.datastructures import FileStorage
from app.decorators import (get_current_user_id, load_card_owned,
load_comment_owned, load_file_accessible_by_uuid)
from app.schemas import (FileAttachmentWithUrlResponse, FileDeleteResponse,
MultipleFilesResponse)
from app.services.storage.attachment_service import AttachmentService
from app.services.storage.file_service import FileService
from . import kanban_bp
@kanban_bp.route("/files/<string:file_uuid>", methods=["GET"])
@jwt_required()
@load_file_accessible_by_uuid
def get_file(file_uuid, file):
"""Get file information with UUID-based URLs"""
attachment_service = AttachmentService()
file_info = attachment_service.get_attachment_with_url(file.id)
return file_info, 200
@kanban_bp.route("/files/<string:file_uuid>", methods=["DELETE"])
@jwt_required()
@load_file_accessible_by_uuid
def delete_file(file_uuid, file):
"""Delete a file"""
attachment_service = AttachmentService()
success = attachment_service.delete_attachment(file.id)
if not success:
return {"error": "Failed to delete file"}, 500
return FileDeleteResponse(message="File deleted", file_id=file.id).model_dump(), 200
@kanban_bp.route("/cards/<int:card_id>/attachments", methods=["GET"])
@jwt_required()
@load_card_owned
def get_card_attachments(card_id, card):
"""Get all attachments for a card"""
attachment_service = AttachmentService()
attachments = attachment_service.get_card_attachments(card_id)
# Get URLs for each attachment
files_with_urls = []
for attachment in attachments:
file_info = attachment_service.get_attachment_with_url(attachment.id)
files_with_urls.append(file_info)
return (
MultipleFilesResponse(
files=files_with_urls,
count=len(attachments),
).model_dump(),
200,
)
@kanban_bp.route("/cards/<int:card_id>/attachments", methods=["POST"])
@jwt_required()
@load_card_owned
def upload_card_attachment(card_id, card):
"""Upload a file to a specific card"""
return _upload_file(entity_type="Card", entity_id=card_id)
@kanban_bp.route("/comments/<int:comment_id>/attachments", methods=["GET"])
@jwt_required()
@load_comment_owned
def get_comment_attachments(comment_id, comment):
"""Get all attachments for a comment"""
attachment_service = AttachmentService()
attachments = attachment_service.get_comment_attachments(comment_id)
# Get URLs for each attachment
files_with_urls = []
for attachment in attachments:
file_info = attachment_service.get_attachment_with_url(attachment.id)
files_with_urls.append(file_info)
return (
MultipleFilesResponse(
files=files_with_urls,
count=len(attachments),
).model_dump(),
200,
)
@kanban_bp.route("/comments/<int:comment_id>/attachments", methods=["POST"])
@jwt_required()
@load_comment_owned
def upload_comment_attachment(comment_id, comment):
"""Upload a file to a specific comment"""
return _upload_file(entity_type="Comment", entity_id=comment_id)
@kanban_bp.route("/files/upload", methods=["POST"])
@jwt_required()
def upload_file():
"""Upload a file to an entity (Card, Comment, etc.)"""
# Validate request
entity_type = request.form.get("entity_type")
entity_id = request.form.get("entity_id")
if not entity_type or not entity_id:
return {"error": "entity_type and entity_id are required"}, 400
# Validate entity_type
valid_types = ["Card", "Comment", "Epic", "Board"]
if entity_type not in valid_types:
return {
"error": f"Invalid entity_type: {entity_type}. Must be one of {valid_types}"
}, 400
return _upload_file(entity_type=entity_type, entity_id=int(entity_id))
def _upload_file(entity_type: str, entity_id: int):
"""
Helper function to handle file upload
Args:
entity_type: Type of entity (Card, Comment, etc.)
entity_id: ID of the entity
Returns:
tuple: (response_dict, status_code)
"""
# Get file from request
if "file" not in request.files:
return {"error": "No file provided"}, 400
file: FileStorage = request.files["file"]
if file.filename == "":
return {"error": "No file selected"}, 400
# Get user ID
user_id = get_current_user_id()
# Create BytesIO from file
file_data = BytesIO()
file.save(file_data)
file_size = len(file_data.getvalue())
# Get MIME type
mime_type = file.mimetype or "application/octet-stream"
# Upload file
attachment_service = AttachmentService()
attachment = attachment_service.attach_file_to_entity(
entity_type=entity_type,
entity_id=entity_id,
file_data=file_data,
filename=file.filename,
content_type=mime_type,
file_size=file_size,
uploaded_by=user_id,
)
# Get attachment with URLs for response
file_info = attachment_service.get_attachment_with_url(attachment.id)
return FileAttachmentWithUrlResponse(**file_info).model_dump(), 201
@kanban_bp.route("/files/<string:file_uuid>/download", methods=["GET"])
@jwt_required()
@load_file_accessible_by_uuid
def download_file(file_uuid, file):
"""
Download a file through secure Flask proxy using UUID
This route serves as a proxy to MinIO, hiding the MinIO server
from the frontend and ensuring authentication/authorization.
Args:
file_uuid: UUID of the file attachment
file: FileAttachment (injected by @load_file_accessible_by_uuid decorator)
Returns:
Response: File data with proper headers
"""
file_service = FileService()
# Download file from MinIO
file_data = file_service.storage.download_file(
file.minio_bucket, file.minio_object_name
)
# Get file info for proper headers
file_info = file_service.storage.get_file_info(
file.minio_bucket, file.minio_object_name
)
# Create Flask Response with file data
return Response(
file_data.getvalue(),
mimetype=file.mime_type,
headers={
"Content-Disposition": f'attachment; filename="{file.original_name}"',
"Content-Length": str(file_info["size"]),
"Cache-Control": "private, max-age=3600", # Cache for 1 hour
},
)
@kanban_bp.route("/files/<string:file_uuid>/thumbnail", methods=["GET"])
@jwt_required()
@load_file_accessible_by_uuid
def download_thumbnail(file_uuid, file):
"""
Download a file thumbnail through secure Flask proxy using UUID
This route serves as a proxy to MinIO for thumbnails,
ensuring authentication with JWT.
Args:
file_uuid: UUID of the file attachment
file: FileAttachment (injected by @load_file_accessible_by_uuid decorator)
Returns:
Response: Thumbnail image data with proper headers
"""
# Check if thumbnail exists
if not file.thumbnail_minio_bucket or not file.thumbnail_minio_object_name:
return {"error": "Thumbnail not available"}, 404
file_service = FileService()
# Download thumbnail from MinIO
thumbnail_data = file_service.storage.download_file(
file.thumbnail_minio_bucket,
file.thumbnail_minio_object_name,
)
# Get thumbnail info for proper headers
thumb_info = file_service.storage.get_file_info(
file.thumbnail_minio_bucket,
file.thumbnail_minio_object_name,
)
# Create Flask Response with thumbnail data
return Response(
thumbnail_data.getvalue(),
mimetype="image/jpeg",
headers={
"Content-Length": str(thumb_info["size"]),
"Cache-Control": "private, max-age=3600",
},
)
@kanban_bp.route("/files/<string:file_uuid>/view", methods=["GET"])
@jwt_required()
@load_file_accessible_by_uuid
def view_file(file_uuid, file):
"""
View a file inline through secure Flask proxy using UUID
Similar to download, but uses inline disposition so browsers
display the file instead of downloading it.
Args:
file_uuid: UUID of the file attachment
file: FileAttachment (injected by @load_file_accessible_by_uuid decorator)
Returns:
Response: File data with proper headers
"""
file_service = FileService()
# Download file from MinIO
file_data = file_service.storage.download_file(
file.minio_bucket, file.minio_object_name
)
# Get file info for proper headers
file_info = file_service.storage.get_file_info(
file.minio_bucket, file.minio_object_name
)
# Create Flask Response with file data (inline disposition)
return Response(
file_data.getvalue(),
mimetype=file.mime_type,
headers={
"Content-Disposition": f'inline; filename="{file.original_name}"',
"Content-Length": str(file_info["size"]),
"Cache-Control": "private, max-age=3600", # Cache for 1 hour
},
)

View file

@ -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/<int:board_id>/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/<int:board_id>/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/<int:wiki_id>", 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/<int:wiki_id>", 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/<int:wiki_id>", 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/<int:wiki_id>/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/<int:wiki_id>/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

View file

@ -7,14 +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",
@ -36,7 +41,25 @@ __all__ = [
# Comment schemas
"CommentCreateRequest",
"CommentResponse",
# File Attachment schemas
"FileAttachmentWithUrlResponse",
"FileDeleteResponse",
"FileUploadRequest",
"MultipleFilesResponse",
"ValidationError",
# Composite schemas
"BoardWithDetailsResponse",
"CardWithDetailsResponse",
"CommentWithUserResponse",
# Epic schemas
"EpicCreateRequest",
"EpicResponse",
"EpicUpdateRequest",
"EpicWithCardsResponse",
# Wiki schemas
"WikiCreateRequest",
"WikiResponse",
"WikiUpdateRequest",
"WikiEntityLink",
"WikiLinkRequest",
]

View file

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

139
backend/app/schemas/epic.py Normal file
View file

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

View file

@ -0,0 +1,72 @@
"""Pydantic schemas for file attachments"""
from datetime import datetime
from typing import Optional
from pydantic import (BaseModel, ConfigDict, Field, field_serializer,
field_validator)
class FileUploadRequest(BaseModel):
"""Schema for file upload request"""
entity_type: str = Field(..., description="Type of entity (Card, Comment, etc.)")
entity_id: int = Field(..., description="ID of entity")
@field_validator("entity_type")
@classmethod
def validate_entity_type(cls, v: str) -> str:
"""Validate entity type is allowed"""
valid_types = ["Card", "Comment", "Epic", "Board"]
if v not in valid_types:
raise ValueError(f"Invalid entity_type: {v}. Must be one of {valid_types}")
return v
model_config = ConfigDict(from_attributes=True)
class FileAttachmentWithUrlResponse(BaseModel):
"""
Schema for file attachment with secure URLs
Security: Only exposes public-facing information.
Internal details (MinIO paths, IDs, etc.) are hidden.
"""
uuid: str
original_name: str
file_type: str
file_size: int
created_at: datetime
download_url: str
thumbnail_url: Optional[str] = None
view_url: str
mime_type: str
@field_serializer("created_at")
def serialize_created_at(self, value: datetime) -> str:
"""Serialize datetime to ISO format string"""
return value.isoformat() if value else None
model_config = ConfigDict(from_attributes=True)
class FileDeleteResponse(BaseModel):
"""Schema for file delete response"""
message: str = Field(..., description="Deletion status message")
file_id: int = Field(..., description="ID of deleted file")
class MultipleFilesResponse(BaseModel):
"""Schema for multiple files response"""
files: list[FileAttachmentWithUrlResponse]
count: int = Field(..., description="Number of files")
class ValidationError(BaseModel):
"""Schema for validation errors"""
error: str = Field(..., description="Error message")
details: Optional[dict] = None

View file

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

174
backend/app/schemas/wiki.py Normal file
View file

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

View file

@ -0,0 +1,20 @@
"""Storage service module for file upload operations"""
from app.services.storage.attachment_service import AttachmentService
from app.services.storage.base import StorageAdapter
from app.services.storage.file_processor import (FileProcessor, ImageProcessor,
PDFProcessor)
from app.services.storage.file_service import FileService
from app.services.storage.minio_client import MinIOStorageAdapter
from app.services.storage.storage_extension import storage
__all__ = [
"StorageAdapter",
"MinIOStorageAdapter",
"FileProcessor",
"ImageProcessor",
"PDFProcessor",
"FileService",
"AttachmentService",
"storage",
]

View file

@ -0,0 +1,231 @@
"""Service for managing file attachments to entities"""
from io import BytesIO
from typing import List, Optional
from flask import current_app
from app.models import Card, Comment, FileAttachment
from app.services.storage.file_service import FileService
class AttachmentService:
"""Service for managing file attachments to Cards, Comments, etc."""
def __init__(self, file_service: Optional[FileService] = None):
"""
Initialize AttachmentService with dependency injection
Args:
file_service: FileService instance
(defaults to new instance with dependency injection)
"""
self.file_service = file_service or FileService()
def attach_file_to_card(
self,
card_id: int,
file_data: BytesIO,
filename: str,
content_type: str,
file_size: int,
uploaded_by: int,
) -> FileAttachment:
"""
Attach a file to a card
Args:
card_id: ID of the card
file_data: File data as BytesIO
filename: Original filename
content_type: MIME type
file_size: File size in bytes
uploaded_by: User ID who uploaded the file
Returns:
FileAttachment: Created attachment record
"""
# Verify card exists
card = Card.query.get(card_id)
if not card:
raise ValueError(f"Card with ID {card_id} not found")
# Upload file
return self.file_service.upload_file(
file_data=file_data,
filename=filename,
content_type=content_type,
file_size=file_size,
uploaded_by=uploaded_by,
attachable_type="Card",
attachable_id=card_id,
)
def attach_file_to_comment(
self,
comment_id: int,
file_data: BytesIO,
filename: str,
content_type: str,
file_size: int,
uploaded_by: int,
) -> FileAttachment:
"""
Attach a file to a comment
Args:
comment_id: ID of the comment
file_data: File data as BytesIO
filename: Original filename
content_type: MIME type
file_size: File size in bytes
uploaded_by: User ID who uploaded the file
Returns:
FileAttachment: Created attachment record
"""
# Verify comment exists
comment = Comment.query.get(comment_id)
if not comment:
raise ValueError(f"Comment with ID {comment_id} not found")
# Upload file
return self.file_service.upload_file(
file_data=file_data,
filename=filename,
content_type=content_type,
file_size=file_size,
uploaded_by=uploaded_by,
attachable_type="Comment",
attachable_id=comment_id,
)
def attach_file_to_entity(
self,
entity_type: str,
entity_id: int,
file_data: BytesIO,
filename: str,
content_type: str,
file_size: int,
uploaded_by: int,
) -> FileAttachment:
"""
Attach a file to any entity type
Args:
entity_type: Type of entity (Card, Comment, etc.)
entity_id: ID of the entity
file_data: File data as BytesIO
filename: Original filename
content_type: MIME type
file_size: File size in bytes
uploaded_by: User ID who uploaded the file
Returns:
FileAttachment: Created attachment record
"""
# Validate entity type
valid_types = ["Card", "Comment", "Epic", "Board"]
if entity_type not in valid_types:
raise ValueError(
f"Invalid entity type: {entity_type}. Must be one of {valid_types}"
)
# Upload file
return self.file_service.upload_file(
file_data=file_data,
filename=filename,
content_type=content_type,
file_size=file_size,
uploaded_by=uploaded_by,
attachable_type=entity_type,
attachable_id=entity_id,
)
def get_card_attachments(self, card_id: int) -> List[FileAttachment]:
"""
Get all attachments for a card
Args:
card_id: ID of the card
Returns:
list: List of FileAttachment objects
"""
return self.file_service.get_files_for_entity("Card", card_id)
def get_comment_attachments(self, comment_id: int) -> List[FileAttachment]:
"""
Get all attachments for a comment
Args:
comment_id: ID of the comment
Returns:
list: List of FileAttachment objects
"""
return self.file_service.get_files_for_entity("Comment", comment_id)
def get_entity_attachments(
self, entity_type: str, entity_id: int
) -> List[FileAttachment]:
"""
Get all attachments for any entity type
Args:
entity_type: Type of entity (Card, Comment, etc.)
entity_id: ID of the entity
Returns:
list: List of FileAttachment objects
"""
return self.file_service.get_files_for_entity(entity_type, entity_id)
def get_attachment_with_url(self, attachment_id: int) -> dict:
"""
Get attachment with download URL
Args:
attachment_id: ID of the attachment
Returns:
dict: Attachment information with URLs
"""
return self.file_service.get_file_info(attachment_id)
def delete_attachment(self, attachment_id: int) -> bool:
"""
Delete an attachment
Args:
attachment_id: ID of the attachment
Returns:
bool: True if successful
"""
return self.file_service.delete_file(attachment_id)
def delete_entity_attachments(self, entity_type: str, entity_id: int) -> int:
"""
Delete all attachments for an entity (cascade delete)
Args:
entity_type: Type of entity (Card, Comment, etc.)
entity_id: ID of the entity
Returns:
int: Number of attachments deleted
"""
attachments = self.get_entity_attachments(entity_type, entity_id)
deleted_count = 0
for attachment in attachments:
if self.delete_attachment(attachment.id):
deleted_count += 1
current_app.logger.info(
f"Deleted {deleted_count} attachments for {entity_type} {entity_id}"
)
return deleted_count

View file

@ -0,0 +1,113 @@
"""Abstract base class for storage adapters"""
from abc import ABC, abstractmethod
from io import BytesIO
class StorageAdapter(ABC):
"""Abstract base class for storage adapters"""
@abstractmethod
def upload_file(
self,
file_data: BytesIO,
bucket_name: str,
object_name: str,
content_type: str,
) -> dict:
"""
Upload a file to storage
Args:
file_data: File data as BytesIO
bucket_name: Name of the bucket
object_name: Name of the object in storage
content_type: MIME type of the file
Returns:
dict: Upload result with metadata
"""
pass
@abstractmethod
def download_file(self, bucket_name: str, object_name: str) -> BytesIO:
"""
Download a file from storage
Args:
bucket_name: Name of the bucket
object_name: Name of the object in storage
Returns:
BytesIO: File data
"""
pass
@abstractmethod
def delete_file(self, bucket_name: str, object_name: str) -> bool:
"""
Delete a file from storage
Args:
bucket_name: Name of the bucket
object_name: Name of the object in storage
Returns:
bool: True if successful
"""
pass
@abstractmethod
def generate_presigned_url(
self, bucket_name: str, object_name: str, expires_in: int = 3600
) -> str:
"""
Generate a presigned URL for file access
Args:
bucket_name: Name of the bucket
object_name: Name of the object in storage
expires_in: URL expiration time in seconds
Returns:
str: Presigned URL
"""
pass
@abstractmethod
def file_exists(self, bucket_name: str, object_name: str) -> bool:
"""
Check if a file exists in storage
Args:
bucket_name: Name of the bucket
object_name: Name of the object in storage
Returns:
bool: True if file exists
"""
pass
@abstractmethod
def get_file_info(self, bucket_name: str, object_name: str) -> dict:
"""
Get file metadata
Args:
bucket_name: Name of the bucket
object_name: Name of the object in storage
Returns:
dict: File metadata
"""
pass
@abstractmethod
def ensure_bucket_exists(self, bucket_name: str) -> None:
"""
Ensure a bucket exists, create if it doesn't
Args:
bucket_name: Name of the bucket
"""
pass

View file

@ -0,0 +1,264 @@
"""File processor classes for different file types"""
from abc import ABC, abstractmethod
from io import BytesIO
from typing import Optional, Tuple
from flask import current_app
from PIL import Image
class FileProcessor(ABC):
"""Abstract base class for file processors"""
@abstractmethod
def validate(self, file_data: BytesIO, file_size: int, mime_type: str) -> bool:
"""
Validate file
Args:
file_data: File data as BytesIO
file_size: Size of the file in bytes
mime_type: MIME type of the file
Returns:
bool: True if valid
"""
pass
@abstractmethod
def process(self, file_data: BytesIO, mime_type: str) -> dict:
"""
Process file and return metadata
Args:
file_data: File data as BytesIO
mime_type: MIME type of the file
Returns:
dict: Processing results and metadata
"""
pass
@abstractmethod
def get_file_type(self) -> str:
"""Return the file type (image, pdf, document, etc.)"""
pass
class ImageProcessor(FileProcessor):
"""Processor for image files"""
def __init__(self):
self.supported_formats = {
"image/jpeg": "jpeg",
"image/png": "png",
"image/gif": "gif",
"image/webp": "webp",
"image/svg+xml": "svg+xml",
}
def validate(self, file_data: BytesIO, file_size: int, mime_type: str) -> bool:
"""Validate image file"""
# Check file size
if file_size > current_app.config["MAX_UPLOAD_SIZE_IMAGE"]:
current_app.logger.error(
f"Image size {file_size} exceeds "
+ "maximum {current_app.config['MAX_UPLOAD_SIZE_IMAGE']}"
)
return False
# Check MIME type
if mime_type not in current_app.config["ALLOWED_IMAGE_TYPES"]:
current_app.logger.error(f"Unsupported image MIME type: {mime_type}")
return False
# Verify it's actually an image
try:
file_data.seek(0)
img = Image.open(file_data)
img.verify()
file_data.seek(0)
return True
except Exception as e:
current_app.logger.error(f"Image validation failed: {e}")
return False
def process(self, file_data: BytesIO, mime_type: str) -> dict:
"""Process image and extract metadata"""
file_data.seek(0)
img = Image.open(file_data)
metadata = {
"width": img.width,
"height": img.height,
"format": img.format,
"mode": img.mode,
"file_type": "image",
}
# Generate thumbnail
thumbnail_data = self._generate_thumbnail(img)
file_data.seek(0)
return {
"metadata": metadata,
"thumbnail_data": thumbnail_data,
}
def _generate_thumbnail(
self, img: Image.Image, size: Tuple[int, int] = (200, 200)
) -> Optional[BytesIO]:
"""
Generate thumbnail for image
Args:
img: PIL Image object
size: Thumbnail size (width, height)
Returns:
BytesIO: Thumbnail data or None
"""
try:
# Create thumbnail
img_copy = img.copy()
img_copy.thumbnail(size, Image.Resampling.LANCZOS)
# Convert RGBA to RGB if necessary (JPEG doesn't support transparency)
if img_copy.mode in ("RGBA", "LA", "P"):
# Create white background
background = Image.new("RGB", img_copy.size, (255, 255, 255))
if img_copy.mode == "P":
img_copy = img_copy.convert("RGBA")
if img_copy.mode in ("RGBA", "LA"):
background.paste(img_copy, mask=img_copy.split()[-1])
else:
background.paste(img_copy)
img_copy = background
elif img_copy.mode != "RGB":
img_copy = img_copy.convert("RGB")
# Save to BytesIO
thumb_data = BytesIO()
img_copy.save(thumb_data, format="JPEG", quality=85)
thumb_data.seek(0)
return thumb_data
except Exception as e:
current_app.logger.error(f"Thumbnail generation failed: {e}")
return None
def get_file_type(self) -> str:
return "image"
class PDFProcessor(FileProcessor):
"""Processor for PDF files"""
def validate(self, file_data: BytesIO, file_size: int, mime_type: str) -> bool:
"""Validate PDF file"""
# Check file size
if file_size > current_app.config["MAX_UPLOAD_SIZE_DOCUMENT"]:
current_app.logger.error(
f"PDF size {file_size} exceeds "
f"maximum {current_app.config['MAX_UPLOAD_SIZE_DOCUMENT']}"
)
return False
# Check MIME type
if mime_type not in current_app.config["ALLOWED_DOCUMENT_TYPES"]:
current_app.logger.error(f"Unsupported document MIME type: {mime_type}")
return False
# Verify it's actually a PDF
try:
file_data.seek(0)
header = file_data.read(4)
file_data.seek(0)
if header != b"%PDF":
current_app.logger.error("Invalid PDF header")
return False
return True
except Exception as e:
current_app.logger.error(f"PDF validation failed: {e}")
return False
def process(self, file_data: BytesIO, mime_type: str) -> dict:
"""Process PDF and extract metadata"""
file_data.seek(0)
# Basic PDF metadata extraction
# Note: For more detailed extraction, you might want to use PyPDF2 or similar
metadata = {
"file_type": "pdf",
}
return {
"metadata": metadata,
"thumbnail_data": None, # PDFs don't have thumbnails by default
}
def get_file_type(self) -> str:
return "pdf"
class DocumentProcessor(FileProcessor):
"""Processor for other document types (placeholder for future expansion)"""
def validate(self, file_data: BytesIO, file_size: int, mime_type: str) -> bool:
"""Validate document file"""
# Basic validation - can be extended for specific document types
if file_size > current_app.config["MAX_UPLOAD_SIZE_DOCUMENT"]:
current_app.logger.error("Document size exceeds maximum")
return False
return True
def process(self, file_data: BytesIO, mime_type: str) -> dict:
"""Process document"""
return {
"metadata": {"file_type": "document"},
"thumbnail_data": None,
}
def get_file_type(self) -> str:
return "document"
class ProcessorFactory:
"""Factory for creating appropriate file processors"""
@staticmethod
def get_processor(mime_type: str) -> FileProcessor:
"""
Get appropriate processor for MIME type
Args:
mime_type: MIME type of the file
Returns:
FileProcessor: Appropriate processor instance
"""
if mime_type.startswith("image/"):
return ImageProcessor()
elif mime_type == "application/pdf":
return PDFProcessor()
else:
return DocumentProcessor()
@staticmethod
def get_file_type_from_mime(mime_type: str) -> str:
"""
Get file type category from MIME type
Args:
mime_type: MIME type of the file
Returns:
str: File type category
"""
if mime_type.startswith("image/"):
return "image"
elif mime_type == "application/pdf":
return "pdf"
else:
return "document"

View file

@ -0,0 +1,315 @@
"""Main file service for handling file uploads"""
from io import BytesIO
from typing import Any, Dict, Optional
from flask import current_app
from app import db
from app.models import FileAttachment
from app.services.storage.base import StorageAdapter
from app.services.storage.file_processor import ProcessorFactory
class FileService:
"""Service for handling file upload and storage operations"""
def __init__(self, storage_adapter: Optional[StorageAdapter] = None):
"""
Initialize FileService with dependency injection
Args:
storage_adapter: Storage adapter
(optional, defaults to factory-provided adapter)
"""
if storage_adapter:
self.storage = storage_adapter
else:
from app.services.storage.storage_factory import StorageFactory
self.storage = StorageFactory.get_storage()
def upload_file(
self,
file_data: BytesIO,
filename: str,
content_type: str,
file_size: int,
uploaded_by: int,
attachable_type: str,
attachable_id: int,
) -> FileAttachment:
"""
Upload a file and create FileAttachment record
Args:
file_data: File data as BytesIO
filename: Original filename
content_type: MIME type
file_size: File size in bytes
uploaded_by: User ID who uploaded the file
attachable_type: Type of entity (Card, Comment, etc.)
attachable_id: ID of the entity
Returns:
FileAttachment: Created attachment record
"""
# Get appropriate processor
processor = ProcessorFactory.get_processor(content_type)
# Validate file
if not processor.validate(file_data, file_size, content_type):
raise ValueError("File validation failed")
# Process file and get metadata (this also generates thumbnail)
file_data.seek(0) # Ensure file is at start
process_result = processor.process(file_data, content_type)
metadata = process_result["metadata"]
thumbnail_data = process_result["thumbnail_data"]
# Seek back to start for main file upload
file_data.seek(0)
# Determine bucket
bucket_name = self._get_bucket_for_type(content_type)
# Generate unique object name
object_name = self.storage.generate_unique_object_name(filename)
# Upload main file
self.storage.upload_file(
file_data, bucket_name, object_name, content_type, file_size
)
# Upload thumbnail if available
thumbnail_info = {}
if thumbnail_data:
try:
thumb_bucket = current_app.config["MINIO_THUMBNAILS_BUCKET"]
thumb_object_name = f"thumb_{object_name}"
# Get thumbnail length
thumbnail_data.seek(0, 2)
thumb_length = thumbnail_data.tell()
thumbnail_data.seek(0)
# Upload thumbnail
self.storage.upload_file(
thumbnail_data,
thumb_bucket,
thumb_object_name,
"image/jpeg",
length=thumb_length,
)
thumbnail_info = {
"thumbnail_minio_object_name": thumb_object_name,
"thumbnail_minio_bucket": thumb_bucket,
}
current_app.logger.info(
f"Thumbnail uploaded: {thumb_bucket}/{thumb_object_name}"
)
except Exception as e:
current_app.logger.error(f"Failed to upload thumbnail: {e}")
# Continue without thumbnail
# Create database record
attachment = FileAttachment(
filename=object_name,
original_name=filename,
file_type=metadata.get("file_type", "document"),
mime_type=content_type,
file_size=file_size,
minio_bucket=bucket_name,
minio_object_name=object_name,
uploaded_by=uploaded_by,
attachable_type=attachable_type,
attachable_id=attachable_id,
**thumbnail_info,
)
db.session.add(attachment)
db.session.commit()
current_app.logger.info(
f"File uploaded: {filename} -> {bucket_name}/{object_name}"
)
return attachment
def delete_file(self, attachment_id: int) -> bool:
"""
Delete a file and its attachment record
Args:
attachment_id: ID of the FileAttachment
Returns:
bool: True if successful
"""
attachment = db.session.get(FileAttachment, attachment_id)
if not attachment:
return False
# Delete from MinIO
try:
self.storage.delete_file(
attachment.minio_bucket, attachment.minio_object_name
)
# Delete thumbnail if exists
if (
attachment.thumbnail_minio_bucket
and attachment.thumbnail_minio_object_name
):
self.storage.delete_file(
attachment.thumbnail_minio_bucket,
attachment.thumbnail_minio_object_name,
)
except Exception as e:
current_app.logger.error(f"Error deleting file from MinIO: {e}")
# Delete from database
db.session.delete(attachment)
db.session.commit()
current_app.logger.info(f"File deleted: {attachment.original_name}")
return True
def get_file_url(self, attachment_id: int, expires_in: int = 3600) -> str:
"""
Generate a presigned URL for file access
Args:
attachment_id: ID of the FileAttachment
expires_in: URL expiration time in seconds
Returns:
str: Presigned URL
"""
attachment = db.session.get(FileAttachment, attachment_id)
if not attachment:
raise ValueError("Attachment not found")
return self.storage.generate_presigned_url(
attachment.minio_bucket,
attachment.minio_object_name,
expires_in,
)
def get_thumbnail_url(
self, attachment_id: int, expires_in: int = 3600
) -> Optional[str]:
"""
Generate a presigned URL for thumbnail access
Args:
attachment_id: ID of the FileAttachment
expires_in: URL expiration time in seconds
Returns:
str: Presigned URL or None if no thumbnail
"""
attachment = db.session.get(FileAttachment, attachment_id)
if not attachment or not attachment.thumbnail_minio_bucket:
return None
return self.storage.generate_presigned_url(
attachment.thumbnail_minio_bucket,
attachment.thumbnail_minio_object_name,
expires_in,
)
def _get_bucket_for_type(self, mime_type: str) -> str:
"""
Get appropriate bucket for MIME type
Args:
mime_type: MIME type of the file
Returns:
str: Bucket name
"""
if mime_type.startswith("image/"):
return current_app.config["MINIO_IMAGES_BUCKET"]
elif mime_type == "application/pdf":
return current_app.config["MINIO_DOCUMENTS_BUCKET"]
else:
return current_app.config["MINIO_DOCUMENTS_BUCKET"]
def get_file_by_id(self, attachment_id: int) -> Optional[FileAttachment]:
"""
Get file attachment by ID
Args:
attachment_id: ID of the FileAttachment
Returns:
FileAttachment or None
"""
return db.session.get(FileAttachment, attachment_id)
def get_files_for_entity(
self, attachable_type: str, attachable_id: int
) -> list[FileAttachment]:
"""
Get all files attached to an entity
Args:
attachable_type: Type of entity (Card, Comment, etc.)
attachable_id: ID of the entity
Returns:
list: List of FileAttachment objects
"""
return FileAttachment.query.filter_by(
attachable_type=attachable_type, attachable_id=attachable_id
).all()
def get_file_info(self, attachment_id: int) -> Optional[Dict[str, Any]]:
"""
Get file information including UUID-based secure URLs
Security: Only exposes public-facing information.
Internal details (MinIO paths, IDs, entity references) are hidden.
Uses UUID for secure file access:
- UUIDs are non-sequential and hard to guess
- All endpoints require JWT authentication
- MinIO server stays hidden from public
- Frontend uses blob URLs for secure image display
Args:
attachment_id: ID of the FileAttachment
Returns:
dict: File information with UUID-based URLs
"""
attachment = self.get_file_by_id(attachment_id)
if not attachment:
return None
# Use UUID-based secure URLs (all require JWT authentication)
download_url = f"/files/{attachment.uuid}/download"
thumbnail_url = (
f"/files/{attachment.uuid}/thumbnail"
if attachment.thumbnail_minio_object_name
else None
)
view_url = f"/files/{attachment.uuid}/view"
# Only return public-facing fields (security: hide internal implementation)
return {
"uuid": attachment.uuid,
"original_name": attachment.original_name,
"file_type": attachment.file_type,
"file_size": attachment.file_size,
"mime_type": attachment.mime_type,
"created_at": attachment.created_at.isoformat()
if attachment.created_at
else None,
"download_url": download_url,
"thumbnail_url": thumbnail_url,
"view_url": view_url,
}

View file

@ -0,0 +1,222 @@
"""MinIO storage adapter implementation"""
import uuid
from io import BytesIO
from typing import Optional
from flask import current_app
from minio import Minio
from minio.error import S3Error
from app.services.storage.base import StorageAdapter
class MinIOStorageAdapter(StorageAdapter):
"""MinIO implementation of StorageAdapter"""
def __init__(self, minio_client: Optional[Minio] = None):
"""
Initialize MinIO adapter with client dependency
Args:
minio_client: MinIO client
instance (optional, defaults to extension's client)
"""
if minio_client is not None:
self.client = minio_client
else:
# Get client from storage extension (dependency injection)
from app.services.storage.storage_extension import storage
self.client = storage.get_client()
def upload_file(
self,
file_data: BytesIO,
bucket_name: str,
object_name: str,
content_type: str,
length: Optional[int] = None,
) -> dict:
"""
Upload a file to MinIO
Args:
file_data: File data as BytesIO
bucket_name: Name of bucket
object_name: Name of the object in MinIO
content_type: MIME type of the file
length: File size in bytes (optional, will be determined if not provided)
Returns:
dict: Upload result with metadata
"""
try:
# Get file length if not provided
if length is None:
file_data.seek(0, 2) # Seek to end
length = file_data.tell()
file_data.seek(0) # Seek back to start
# Ensure bucket exists
self.ensure_bucket_exists(bucket_name)
# Upload file
result = self.client.put_object(
bucket_name,
object_name,
file_data,
length=length,
content_type=content_type,
)
return {
"success": True,
"bucket": bucket_name,
"object_name": object_name,
"size": length,
"etag": result.etag,
"version_id": result.version_id
if hasattr(result, "version_id")
else None,
}
except S3Error as e:
current_app.logger.error(f"MinIO upload error: {e}")
raise
def download_file(self, bucket_name: str, object_name: str) -> BytesIO:
"""
Download a file from MinIO
Args:
bucket_name: Name of bucket
object_name: Name of the object in MinIO
Returns:
BytesIO: File data
"""
try:
response = self.client.get_object(bucket_name, object_name)
return BytesIO(response.read())
except S3Error as e:
current_app.logger.error(f"MinIO download error: {e}")
raise
def delete_file(self, bucket_name: str, object_name: str) -> bool:
"""
Delete a file from MinIO
Args:
bucket_name: Name of bucket
object_name: Name of the object in MinIO
Returns:
bool: True if successful
"""
try:
self.client.remove_object(bucket_name, object_name)
return True
except S3Error as e:
current_app.logger.error(f"MinIO delete error: {e}")
return False
def generate_presigned_url(
self, bucket_name: str, object_name: str, expires_in: int = 3600
) -> str:
"""
Generate a presigned URL for file access
Args:
bucket_name: Name of bucket
object_name: Name of the object in MinIO
expires_in: URL expiration time in seconds
Returns:
str: Presigned URL
"""
try:
url = self.client.presigned_get_object(
bucket_name, object_name, expires=expires_in
)
return url
except S3Error as e:
current_app.logger.error(f"MinIO presigned URL error: {e}")
raise
def file_exists(self, bucket_name: str, object_name: str) -> bool:
"""
Check if a file exists in MinIO
Args:
bucket_name: Name of bucket
object_name: Name of the object in MinIO
Returns:
bool: True if file exists
"""
try:
self.client.stat_object(bucket_name, object_name)
return True
except S3Error as e:
if e.code == "NoSuchKey":
return False
current_app.logger.error(f"MinIO check exists error: {e}")
return False
def get_file_info(self, bucket_name: str, object_name: str) -> dict:
"""
Get file metadata from MinIO
Args:
bucket_name: Name of bucket
object_name: Name of the object in MinIO
Returns:
dict: File metadata
"""
try:
stat = self.client.stat_object(bucket_name, object_name)
return {
"size": stat.size,
"last_modified": stat.last_modified,
"etag": stat.etag,
"content_type": stat.content_type,
}
except S3Error as e:
current_app.logger.error(f"MinIO get file info error: {e}")
raise
def ensure_bucket_exists(self, bucket_name: str) -> None:
"""
Ensure a bucket exists, create if it doesn't
Args:
bucket_name: Name of the bucket
"""
try:
if not self.client.bucket_exists(bucket_name):
self.client.make_bucket(bucket_name)
current_app.logger.info(f"Created bucket: {bucket_name}")
except S3Error as e:
current_app.logger.error(f"MinIO ensure bucket error: {e}")
raise
def generate_unique_object_name(self, original_filename: str) -> str:
"""
Generate a unique object name for a file
Args:
original_filename: Original filename
Returns:
str: Unique object name
"""
# Extract file extension
ext = (
original_filename.rsplit(".", 1)[1].lower()
if "." in original_filename
else ""
)
# Generate unique filename with UUID
unique_name = f"{uuid.uuid4().hex}"
return f"{unique_name}.{ext}" if ext else unique_name

View file

@ -0,0 +1,217 @@
"""Mock storage adapter for testing (in-memory storage)"""
import uuid
from datetime import datetime, timezone
from io import BytesIO
from app.services.storage.base import StorageAdapter
class MockStorageAdapter(StorageAdapter):
"""In-memory storage adapter for testing purposes"""
# Class-level storage to persist across instances
_files = {} # {(bucket, object_name): BytesIO}
_file_info = {} # {(bucket, object_name): metadata}
_buckets = set() # Track created buckets
def __init__(self):
"""Initialize mock storage (storage is shared at class level)"""
pass
def upload_file(
self,
file_data: BytesIO,
bucket_name: str,
object_name: str,
content_type: str,
length: int = None,
) -> dict:
"""
Upload a file to in-memory storage
Args:
file_data: File data as BytesIO
bucket_name: Name of bucket
object_name: Name of object in storage
content_type: MIME type of file
length: File size in bytes (optional)
Returns:
dict: Upload result with metadata
"""
# Ensure bucket exists
self.ensure_bucket_exists(bucket_name)
# Get file length if not provided
if length is None:
file_data.seek(0, 2) # Seek to end
length = file_data.tell()
file_data.seek(0) # Seek back to start
# Store file data (create a copy to avoid reference issues)
file_data.seek(0)
file_copy = BytesIO(file_data.read())
# Store in memory
key = (bucket_name, object_name)
self._files[key] = file_copy
# Store metadata
metadata = {
"size": length,
"content_type": content_type,
"last_modified": datetime.now(timezone.utc),
"etag": uuid.uuid4().hex,
}
self._file_info[key] = metadata
return {
"success": True,
"bucket": bucket_name,
"object_name": object_name,
"size": length,
"etag": metadata["etag"],
}
def download_file(self, bucket_name: str, object_name: str) -> BytesIO:
"""
Download a file from in-memory storage
Args:
bucket_name: Name of bucket
object_name: Name of the object in storage
Returns:
BytesIO: File data
Raises:
ValueError: If file doesn't exist
"""
key = (bucket_name, object_name)
if key not in self._files:
raise ValueError(f"File {bucket_name}/{object_name} not found")
# Create a copy to avoid modifying the original
self._files[key].seek(0)
return BytesIO(self._files[key].read())
def delete_file(self, bucket_name: str, object_name: str) -> bool:
"""
Delete a file from in-memory storage
Args:
bucket_name: Name of bucket
object_name: Name of the object in storage
Returns:
bool: True if successful, False if file doesn't exist
"""
key = (bucket_name, object_name)
if key not in self._files:
return False
# Remove from storage
del self._files[key]
del self._file_info[key]
return True
def generate_presigned_url(
self, bucket_name: str, object_name: str, expires_in: int = 3600
) -> str:
"""
Generate a mock presigned URL
In tests, this returns a fake URL that looks like a real MinIO URL
but doesn't actually need to work since we use Flask proxy routes.
Args:
bucket_name: Name of bucket
object_name: Name of the object in storage
expires_in: URL expiration time in seconds (ignored in mock)
Returns:
str: Mock presigned URL
"""
return f"http://minio:9000/{bucket_name}/{object_name}?X-Amz-Token=mock"
def file_exists(self, bucket_name: str, object_name: str) -> bool:
"""
Check if a file exists in in-memory storage
Args:
bucket_name: Name of bucket
object_name: Name of the object in storage
Returns:
bool: True if file exists
"""
return (bucket_name, object_name) in self._files
def get_file_info(self, bucket_name: str, object_name: str) -> dict:
"""
Get file metadata from in-memory storage
Args:
bucket_name: Name of bucket
object_name: Name of the object in storage
Returns:
dict: File metadata
Raises:
ValueError: If file doesn't exist
"""
key = (bucket_name, object_name)
if key not in self._file_info:
raise ValueError(f"File {bucket_name}/{object_name} not found")
return self._file_info[key].copy()
def ensure_bucket_exists(self, bucket_name: str) -> None:
"""
Ensure a bucket exists, create if it doesn't
Args:
bucket_name: Name of bucket
"""
if bucket_name not in self._buckets:
self._buckets.add(bucket_name)
def generate_unique_object_name(self, original_filename: str) -> str:
"""
Generate a unique object name for a file
Args:
original_filename: Original filename
Returns:
str: Unique object name
"""
# Extract file extension
ext = (
original_filename.rsplit(".", 1)[1].lower()
if "." in original_filename
else ""
)
# Generate unique filename with UUID
unique_name = f"{uuid.uuid4().hex}"
return f"{unique_name}.{ext}" if ext else unique_name
@classmethod
def clear_all(cls) -> None:
"""Clear all files and buckets (useful for test cleanup)"""
cls._files.clear()
cls._file_info.clear()
cls._buckets.clear()
@classmethod
def get_all_files(cls) -> list:
"""
Get all stored files (useful for test assertions)
Returns:
list: List of (bucket, object_name) tuples
"""
return list(cls._files.keys())

View file

@ -0,0 +1,123 @@
"""Flask extension for MinIO storage client initialization"""
import logging
from flask import Flask
from minio import Minio
from minio.error import S3Error
class StorageExtension:
"""Flask extension for managing MinIO storage client"""
def __init__(self, app: Flask = None):
"""
Initialize StorageExtension
Args:
app: Flask application instance (optional)
"""
self.app = app
self.client = None
self.logger = logging.getLogger(__name__)
if app is not None:
self.init_app(app)
def init_app(self, app: Flask) -> None:
"""
Initialize the extension with Flask app
Args:
app: Flask application instance
"""
# Store extension on app
app.extensions = getattr(app, "extensions", {})
app.extensions["storage"] = self
# Store app reference for later use
self.app = app
# Initialize and validate MinIO client immediately at app startup
self._initialize_and_validate(app)
def _initialize_and_validate(self, app: Flask) -> None:
"""
Initialize MinIO client and validate connection
Args:
app: Flask application instance
"""
try:
# Create MinIO client
self.client = Minio(
app.config["MINIO_ENDPOINT"],
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
self.client.list_buckets()
app.logger.info("MinIO client initialized and validated successfully")
# Ensure all required buckets exist
self._ensure_all_buckets_exist(app)
except S3Error as e:
app.logger.error(f"Failed to initialize MinIO client: {e}")
raise RuntimeError(
f"Failed to connect to MinIO at {app.config['MINIO_ENDPOINT']}. "
"Please check your MINIO_ENDPOINT, MINIO_ACCESS_KEY, "
"and MINIO_SECRET_KEY configuration."
) from e
except KeyError as e:
app.logger.error(f"Missing MinIO configuration: {e}")
raise RuntimeError(
f"Missing required MinIO configuration: {e}. "
"Please ensure MINIO_ENDPOINT, MINIO_ACCESS_KEY, "
"MINIO_SECRET_KEY are set."
) from e
def _ensure_all_buckets_exist(self, app: Flask) -> None:
"""
Ensure all required buckets exist
Creates buckets if they don't exist
Args:
app: Flask application instance
"""
buckets = [
app.config["MINIO_IMAGES_BUCKET"],
app.config["MINIO_DOCUMENTS_BUCKET"],
app.config["MINIO_THUMBNAILS_BUCKET"],
]
for bucket_name in buckets:
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:
"""
Get the MinIO client instance
Returns:
Minio: Initialized MinIO client
Raises:
RuntimeError: If client has not been initialized
"""
if self.client is None:
raise RuntimeError(
"MinIO client has not been initialized. "
"Ensure the Flask app context is active "
"and before_first_request has run."
)
return self.client
# Create a singleton instance
storage = StorageExtension()

View file

@ -0,0 +1,56 @@
"""Storage factory for creating storage adapters"""
from flask import current_app
class StorageFactory:
"""Factory for creating storage adapter instances"""
@staticmethod
def get_storage():
"""
Get the appropriate storage adapter based on configuration
Returns:
StorageAdapter: Configured storage adapter instance
Factory Pattern Benefits:
- Centralized adapter selection logic
- Easy to add new storage types (S3, GCS, etc.)
- Single Responsibility Principle - FileService only handles file operations
- Easy to test by injecting mock adapters
"""
# Check if we should use mock storage
if current_app.config.get("USE_MOCK_STORAGE", False):
from app.services.storage.mock_adapter import MockStorageAdapter
return MockStorageAdapter()
# Default to MinIO for production/development
from app.services.storage.minio_client import MinIOStorageAdapter
return MinIOStorageAdapter()
@staticmethod
def get_storage_for_testing():
"""
Get mock storage adapter explicitly for testing
Returns:
StorageAdapter: MockStorageAdapter instance
"""
from app.services.storage.mock_adapter import MockStorageAdapter
return MockStorageAdapter()
@staticmethod
def get_minio_storage():
"""
Get MinIO storage adapter explicitly
Returns:
StorageAdapter: MinIOStorageAdapter instance
"""
from app.services.storage.minio_client import MinIOStorageAdapter
return MinIOStorageAdapter()

View file

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

View file

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

View file

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

View file

@ -0,0 +1,54 @@
"""add file upload
Revision ID: c5b574480b80
Revises: 1c0b9dfbd933
Create Date: 2026-03-20 20:16:04.599906
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'c5b574480b80'
down_revision = '1c0b9dfbd933'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('file_attachments',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('filename', sa.String(length=255), nullable=False),
sa.Column('original_name', sa.String(length=255), nullable=False),
sa.Column('file_type', sa.String(length=50), nullable=False),
sa.Column('mime_type', sa.String(length=100), nullable=False),
sa.Column('file_size', sa.Integer(), nullable=False),
sa.Column('minio_bucket', sa.String(length=100), nullable=False),
sa.Column('minio_object_name', sa.String(length=255), nullable=False),
sa.Column('thumbnail_minio_object_name', sa.String(length=255), nullable=True),
sa.Column('thumbnail_minio_bucket', sa.String(length=100), nullable=True),
sa.Column('attachable_type', sa.String(length=50), nullable=False),
sa.Column('attachable_id', sa.Integer(), nullable=False),
sa.Column('uploaded_by', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['uploaded_by'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('minio_object_name')
)
with op.batch_alter_table('file_attachments', schema=None) as batch_op:
batch_op.create_index('ix_file_attachments_attachable', ['attachable_type', 'attachable_id'], unique=False)
batch_op.create_index('ix_file_attachments_user', ['uploaded_by'], unique=False)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('file_attachments', schema=None) as batch_op:
batch_op.drop_index('ix_file_attachments_user')
batch_op.drop_index('ix_file_attachments_attachable')
op.drop_table('file_attachments')
# ### end Alembic commands ###

View file

@ -0,0 +1,59 @@
"""Add UUID to file_attachments
Revision ID: e9515e29ef8b
Revises: c5b574480b80
Create Date: 2026-03-20 21:58:22.006727
"""
from alembic import op
import sqlalchemy as sa
import uuid
# revision identifiers, used by Alembic.
revision = 'e9515e29ef8b'
down_revision = 'c5b574480b80'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
# Step 1: Add UUID column as nullable
op.add_column('file_attachments', sa.Column('uuid', sa.String(length=36), nullable=True))
# Step 2: Populate UUID for existing rows
from sqlalchemy.sql import table, column, select, update
file_attachments_table = table('file_attachments',
column('id', sa.Integer),
column('uuid', sa.String(36))
)
connection = op.get_bind()
# Get all existing file attachments
result = connection.execute(select(file_attachments_table.c.id))
for row in result:
new_uuid = str(uuid.uuid4())
connection.execute(
file_attachments_table.update()
.where(file_attachments_table.c.id == row[0])
.values(uuid=new_uuid)
)
# Step 3: Make UUID not null and add constraints
op.alter_column('file_attachments', 'uuid', nullable=False)
op.create_index('ix_file_attachments_uuid', 'file_attachments', ['uuid'], unique=False)
op.create_unique_constraint('uq_file_attachments_uuid', 'file_attachments', ['uuid'])
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint('uq_file_attachments_uuid', 'file_attachments', type_='unique')
op.drop_index('ix_file_attachments_uuid', table_name='file_attachments')
op.drop_column('file_attachments', 'uuid')
# ### end Alembic commands ###

View file

@ -10,9 +10,11 @@ Werkzeug==3.0.1
SQLAlchemy==2.0.23
celery[redis]==5.3.6
pydantic==2.5.3
minio==7.2.0
Pillow==10.1.0
pytest==7.4.3
pytest-flask==1.3.0
pytest-cov==4.1.0
pytest-mock==3.12.0
factory-boy==3.3.0
faker==20.1.0
faker==20.1.0

View file

@ -1,11 +1,14 @@
"""Pytest configuration and fixtures"""
import logging
import time
from io import BytesIO
import pytest
from faker import Faker
from PIL import Image
from app import create_app, db
from app.models import Order, OrderItem, Product, User
from app.models import Board, Card, Comment, List, User
log = logging.getLogger(__name__)
fake = Faker()
@ -113,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,
@ -142,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"""
@ -200,28 +170,89 @@ def admin_headers(client, admin_user):
return {"Authorization": f"Bearer {token}"}
# ============ File Upload Fixtures ============
@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()
def test_image_file():
"""Create a test image file as BytesIO"""
# Create a simple red PNG image using PIL
img = Image.new("RGB", (10, 10), color="red")
img_io = BytesIO()
img.save(img_io, format="PNG")
img_io.seek(0)
return img_io
@pytest.fixture
def test_large_image_file():
"""Create a larger test image (50KB)"""
# Create a larger image using PIL
img = Image.new("RGB", (200, 200), color="blue")
img_io = BytesIO()
img.save(img_io, format="PNG")
img_io.seek(0)
return img_io
@pytest.fixture
def test_pdf_file():
"""Create a test PDF file as BytesIO"""
# Minimal valid PDF
pdf_data = b"%PDF-1.4\n1 0 obj\n<<\n/Type /Catalog\n>>\nendobj\n%%EOF"
return BytesIO(pdf_data)
@pytest.fixture
def test_board(db_session, regular_user):
"""Create a test board"""
board = Board(
user_id=regular_user.id,
name=fake.sentence(nb_words=4)[:-1],
description=fake.paragraph(),
)
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.add(board)
db_session.commit()
return order
return board
@pytest.fixture
def test_list(db_session, test_board):
"""Create a test list"""
lst = List(
board_id=test_board.id,
name=fake.sentence(nb_words=3)[:-1],
pos=0,
)
db_session.add(lst)
db_session.commit()
return lst
@pytest.fixture
def test_card(db_session, test_list):
"""Create a test card"""
card = Card(
list_id=test_list.id,
name=fake.sentence(nb_words=4)[:-1],
description=fake.paragraph(),
pos=0,
due=None,
board_id=test_list.board_id,
)
db_session.add(card)
db_session.commit()
return card
@pytest.fixture
def test_comment(db_session, test_card, regular_user):
"""Create a test comment"""
comment = Comment(
card_id=test_card.id,
user_id=regular_user.id,
text=fake.paragraph(),
)
db_session.add(comment)
db_session.commit()
return comment

View file

@ -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/<board_id>/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/<board_id>/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/<epic_id> ==========
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/<epic_id> ==========
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/<epic_id> ==========
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/<card_id>/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/<card_id>/epics/<epic_id> ==========
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

View file

@ -0,0 +1,372 @@
"""Integration tests for file upload routes"""
import pytest
@pytest.mark.integration
def test_upload_image_to_card(client, auth_headers, test_card, test_image_file):
"""Test uploading an image to a card"""
data = {
"file": (test_image_file, "test.png", "image/png"),
}
response = client.post(
f"/api/cards/{test_card.id}/attachments",
headers=auth_headers,
data=data,
content_type="multipart/form-data",
)
assert response.status_code == 201
file_data = response.get_json()
# Only check public-facing fields
assert file_data["original_name"] == "test.png"
assert file_data["file_type"] == "image"
assert file_data["file_size"] > 0
assert file_data["mime_type"] == "image/png" # Mime type is now exposed
assert "uuid" in file_data
assert "download_url" in file_data
assert "thumbnail_url" in file_data
assert "view_url" in file_data
assert "created_at" in file_data
# Security: Verify internal details are NOT exposed
assert "id" not in file_data
assert "filename" not in file_data
assert "minio_bucket" not in file_data
assert "minio_object_name" not in file_data
assert "thumbnail_minio_bucket" not in file_data
assert "thumbnail_minio_object_name" not in file_data
assert "attachable_id" not in file_data
assert "attachable_type" not in file_data
assert "uploaded_by" not in file_data
@pytest.mark.integration
def test_upload_pdf_to_card(client, auth_headers, test_card, test_pdf_file):
"""Test uploading a PDF to a card"""
data = {
"file": (test_pdf_file, "test.pdf", "application/pdf"),
}
response = client.post(
f"/api/cards/{test_card.id}/attachments",
headers=auth_headers,
data=data,
content_type="multipart/form-data",
)
assert response.status_code == 201
file_data = response.get_json()
assert file_data["original_name"] == "test.pdf"
assert file_data["file_type"] == "pdf" # PDFProcessor returns "pdf" as file_type
# PDFs don't have thumbnails
assert file_data.get("thumbnail_url") is None
@pytest.mark.integration
def test_upload_file_to_comment(client, auth_headers, test_comment, test_image_file):
"""Test uploading a file to a comment"""
data = {
"file": (test_image_file, "comment_image.png", "image/png"),
}
response = client.post(
f"/api/comments/{test_comment.id}/attachments",
headers=auth_headers,
data=data,
content_type="multipart/form-data",
)
assert response.status_code == 201
file_data = response.get_json()
assert file_data["original_name"] == "comment_image.png"
assert file_data["file_type"] == "image"
@pytest.mark.integration
def test_get_card_attachments(client, auth_headers, test_card, test_image_file):
"""Test getting all attachments for a card"""
# Upload a file first
data = {"file": (test_image_file, "test.png", "image/png")}
client.post(
f"/api/cards/{test_card.id}/attachments",
headers=auth_headers,
data=data,
content_type="multipart/form-data",
)
# Get attachments
response = client.get(
f"/api/cards/{test_card.id}/attachments",
headers=auth_headers,
)
assert response.status_code == 200
attachments_data = response.get_json()
assert attachments_data["count"] == 1
assert len(attachments_data["files"]) == 1
assert attachments_data["files"][0]["original_name"] == "test.png"
# Verify only public fields are exposed
assert "id" not in attachments_data["files"][0]
assert "attachable_id" not in attachments_data["files"][0]
@pytest.mark.integration
def test_get_comment_attachments(client, auth_headers, test_comment, test_image_file):
"""Test getting all attachments for a comment"""
# Upload a file first
data = {"file": (test_image_file, "test.png", "image/png")}
client.post(
f"/api/comments/{test_comment.id}/attachments",
headers=auth_headers,
data=data,
content_type="multipart/form-data",
)
# Get attachments
response = client.get(
f"/api/comments/{test_comment.id}/attachments",
headers=auth_headers,
)
assert response.status_code == 200
attachments_data = response.get_json()
assert attachments_data["count"] == 1
assert len(attachments_data["files"]) == 1
@pytest.mark.integration
def test_get_file_by_uuid(client, auth_headers, test_card, test_image_file):
"""Test getting file info by UUID"""
# Upload a file first
data = {"file": (test_image_file, "test.png", "image/png")}
upload_response = client.post(
f"/api/cards/{test_card.id}/attachments",
headers=auth_headers,
data=data,
content_type="multipart/form-data",
)
file_uuid = upload_response.get_json()["uuid"]
# Get file by UUID
response = client.get(
f"/api/files/{file_uuid}",
headers=auth_headers,
)
assert response.status_code == 200
file_data = response.get_json()
assert file_data["uuid"] == file_uuid
assert file_data["original_name"] == "test.png"
assert "download_url" in file_data
# Verify only public fields
assert "id" not in file_data
assert "attachable_id" not in file_data
@pytest.mark.integration
def test_delete_file(client, auth_headers, test_card, test_image_file):
"""Test deleting a file"""
# Upload a file first
data = {"file": (test_image_file, "test.png", "image/png")}
upload_response = client.post(
f"/api/cards/{test_card.id}/attachments",
headers=auth_headers,
data=data,
content_type="multipart/form-data",
)
file_uuid = upload_response.get_json()["uuid"]
# Delete file
response = client.delete(
f"/api/files/{file_uuid}",
headers=auth_headers,
)
assert response.status_code == 200
delete_data = response.get_json()
assert delete_data["message"] == "File deleted"
# Verify file is deleted
get_response = client.get(
f"/api/files/{file_uuid}",
headers=auth_headers,
)
assert get_response.status_code == 404
@pytest.mark.integration
def test_download_file(client, auth_headers, test_card, test_image_file):
"""Test downloading a file through proxy"""
# Upload a file first
data = {"file": (test_image_file, "test.png", "image/png")}
upload_response = client.post(
f"/api/cards/{test_card.id}/attachments",
headers=auth_headers,
data=data,
content_type="multipart/form-data",
)
file_uuid = upload_response.get_json()["uuid"]
# Download file
response = client.get(
f"/api/files/{file_uuid}/download",
headers=auth_headers,
)
assert response.status_code == 200
assert response.content_type == "image/png"
assert "Content-Disposition" in response.headers
assert "test.png" in response.headers["Content-Disposition"]
@pytest.mark.integration
def test_view_file(client, auth_headers, test_card, test_image_file):
"""Test viewing a file inline through proxy"""
# Upload a file first
data = {"file": (test_image_file, "test.png", "image/png")}
upload_response = client.post(
f"/api/cards/{test_card.id}/attachments",
headers=auth_headers,
data=data,
content_type="multipart/form-data",
)
file_uuid = upload_response.get_json()["uuid"]
# View file inline
response = client.get(
f"/api/files/{file_uuid}/view",
headers=auth_headers,
)
assert response.status_code == 200
assert response.content_type == "image/png"
assert "inline" in response.headers["Content-Disposition"]
@pytest.mark.integration
def test_download_thumbnail(client, auth_headers, test_card, test_image_file):
"""Test downloading a file thumbnail"""
# Upload an image file
data = {"file": (test_image_file, "test.png", "image/png")}
upload_response = client.post(
f"/api/cards/{test_card.id}/attachments",
headers=auth_headers,
data=data,
content_type="multipart/form-data",
)
file_uuid = upload_response.get_json()["uuid"]
# Download thumbnail
response = client.get(
f"/api/files/{file_uuid}/thumbnail",
headers=auth_headers,
)
assert response.status_code == 200
assert response.content_type == "image/jpeg"
@pytest.mark.integration
def test_thumbnail_not_available_for_pdf(
client, auth_headers, test_card, test_pdf_file
):
"""Test that PDF files don't have thumbnails"""
# Upload a PDF file
data = {"file": (test_pdf_file, "test.pdf", "application/pdf")}
upload_response = client.post(
f"/api/cards/{test_card.id}/attachments",
headers=auth_headers,
data=data,
content_type="multipart/form-data",
)
file_uuid = upload_response.get_json()["uuid"]
# Try to download thumbnail
response = client.get(
f"/api/files/{file_uuid}/thumbnail",
headers=auth_headers,
)
assert response.status_code == 404
@pytest.mark.integration
def test_upload_without_authentication(client, test_card, test_image_file):
"""Test uploading a file without authentication"""
data = {"file": (test_image_file, "test.png", "image/png")}
response = client.post(
f"/api/cards/{test_card.id}/attachments",
data=data,
content_type="multipart/form-data",
)
assert response.status_code == 401
@pytest.mark.integration
def test_get_card_attachments_empty(client, auth_headers, test_card):
"""Test getting attachments when card has none"""
response = client.get(
f"/api/cards/{test_card.id}/attachments",
headers=auth_headers,
)
assert response.status_code == 200
attachments_data = response.get_json()
assert attachments_data["count"] == 0
assert len(attachments_data["files"]) == 0
@pytest.mark.integration
def test_upload_without_file(client, auth_headers, test_card):
"""Test uploading without providing a file"""
data = {}
response = client.post(
f"/api/cards/{test_card.id}/attachments",
headers=auth_headers,
data=data,
content_type="multipart/form-data",
)
assert response.status_code == 400
@pytest.mark.integration
def test_upload_with_invalid_card_id(client, auth_headers, test_image_file):
"""Test uploading to a non-existent card"""
data = {"file": (test_image_file, "test.png", "image/png")}
response = client.post(
"/api/cards/99999/attachments",
headers=auth_headers,
data=data,
content_type="multipart/form-data",
)
assert response.status_code == 404
@pytest.mark.integration
def test_get_file_with_invalid_uuid(client, auth_headers):
"""Test getting a file with an invalid UUID"""
response = client.get(
"/api/files/invalid-uuid-12345",
headers=auth_headers,
)
assert response.status_code == 404
@pytest.mark.integration
def test_delete_file_with_invalid_uuid(client, auth_headers):
"""Test deleting a file with an invalid UUID"""
response = client.delete(
"/api/files/invalid-uuid-12345",
headers=auth_headers,
)
assert response.status_code == 404

View file

@ -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/<board_id>/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/<board_id>/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/<wiki_id> ==========
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/<wiki_id> ==========
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/<wiki_id> ==========
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/<wiki_id>/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/<wiki_id>/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

View file

@ -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) == "<User testuser3>"
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) == "<Product Test 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

View file

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

View file

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

View file

@ -23,8 +23,30 @@ services:
- "6381:6379"
restart: unless-stopped
minio:
image: minio/minio:latest
container_name: kanban-app-minio
command: server /data --console-address ":9001"
ports:
- "9000:9000"
- "9001:9001"
environment:
- MINIO_ROOT_USER=${MINIO_ROOT_USER:-minioadmin}
- MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:-minioadmin}
volumes:
- minio-data:/data
networks:
- kanban-app-network
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 30s
timeout: 20s
retries: 3
volumes:
postgres-dev-data:
minio-data:
networks:
kanban-app-network:

View file

@ -55,6 +55,27 @@ services:
- crafting-shop-network
restart: unless-stopped
minio:
image: minio/minio:latest
container_name: crafting-shop-minio
command: server /data --console-address ":9001"
ports:
- "9000:9000"
- "9001:9001"
environment:
- MINIO_ROOT_USER=${MINIO_ROOT_USER:-minioadmin}
- MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:-minioadmin}
volumes:
- minio-data:/data
networks:
- crafting-shop-network
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 30s
timeout: 20s
retries: 3
celery_worker:
build:
context: ./backend
@ -163,6 +184,7 @@ volumes:
grafana-data:
backend-data:
celery-beat-data:
minio-data:
networks:
crafting-shop-network:

View file

@ -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/<board_id>/epics
POST /api/boards/<board_id>/epics
GET /api/epics/<epic_id>
PUT /api/epics/<epic_id>
DELETE /api/epics/<epic_id>
```
### Epic-Card Linking
```
POST /api/cards/<card_id>/epics
DELETE /api/cards/<card_id>/epics/<epic_id>
```
### Wiki Endpoints
```
GET /api/boards/<board_id>/wikis
POST /api/boards/<board_id>/wikis
GET /api/wikis/<wiki_id>
PUT /api/wikis/<wiki_id>
DELETE /api/wikis/<wiki_id>
GET /api/wikis/<wiki_id>/content
```
### Wiki-Entity Linking
```
POST /api/wikis/<wiki_id>/links
DELETE /api/wikis/<wiki_id>/links/<entity_type>/<entity_id>
```
## 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

View file

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

View file

@ -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 = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="3 6 5 6 21 6"></polyline>
@ -386,6 +386,11 @@ const TrashIcon = () => (
</svg>
);
export default TrashIcon;
// ✅ CORRECT - Using icon component
import TrashIcon from "../components/icons/TrashIcon";
function DeleteButton() {
return (
<button className="flex items-center gap-2">
@ -418,14 +423,31 @@ function DeleteButton() {
</button>
);
}
// ❌ WRONG - Inline SVG in component file
function DeleteButton() {
return (
<button className="flex items-center gap-2">
<span className="w-4 h-4">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
</span>
Delete
</button>
);
}
```
**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 `<a>` for navigation (use `<Link>`)
**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

View file

@ -17,12 +17,16 @@
"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": {
"@testing-library/jest-dom": "^6.1.5",
"@testing-library/react": "^14.1.2",
"@testing-library/user-event": "^14.5.1",
"@types/node": "^25.5.0",
"@types/react": "^18.3.28",
"@types/react-dom": "^18.3.7",
"@typescript-eslint/eslint-plugin": "^8.56.1",
@ -1054,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",
@ -1614,6 +1623,15 @@
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"dev": true
},
"node_modules/@types/node": {
"version": "25.5.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz",
"integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==",
"dev": true,
"dependencies": {
"undici-types": "~7.18.0"
}
},
"node_modules/@types/prop-types": {
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
@ -2766,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",
@ -3057,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",
@ -4486,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",
@ -4544,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",
@ -4921,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",
@ -6210,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",
@ -6390,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",
@ -6734,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",
@ -7018,6 +7137,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/undici-types": {
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"dev": true
},
"node_modules/universalify": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz",

View file

@ -23,12 +23,16 @@
"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": {
"@testing-library/jest-dom": "^6.1.5",
"@testing-library/react": "^14.1.2",
"@testing-library/user-event": "^14.5.1",
"@types/node": "^25.5.0",
"@types/react": "^18.3.28",
"@types/react-dom": "^18.3.7",
"@typescript-eslint/eslint-plugin": "^8.56.1",

View file

@ -16,7 +16,16 @@ import { Boards } from './pages/Boards';
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();
@ -35,7 +44,7 @@ const App = () => {
<ModalProvider>
<div className="min-h-screen bg-gray-900 text-gray-100">
<Navbar />
<main className="flex-1 p-8 mx-auto w-full max-w-7xl">
<main>
<Routes>
<Route
path="/"
@ -72,7 +81,9 @@ const App = () => {
path="/boards/:id/edit"
element={
<ProtectedRoute>
<BoardEdit />
<BoardDetailLayout>
<BoardEdit />
</BoardDetailLayout>
</ProtectedRoute>
}
/>
@ -80,7 +91,89 @@ const App = () => {
path="/boards/:id"
element={
<ProtectedRoute>
<BoardDetail />
<BoardDetailLayout>
<BoardDetail />
</BoardDetailLayout>
</ProtectedRoute>
}
/>
<Route
path="/boards/:id/epics"
element={
<ProtectedRoute>
<BoardDetailLayout>
<BoardEpics />
</BoardDetailLayout>
</ProtectedRoute>
}
/>
<Route
path="/boards/:id/epics/new"
element={
<ProtectedRoute>
<BoardDetailLayout>
<CreateEpic />
</BoardDetailLayout>
</ProtectedRoute>
}
/>
<Route
path="/boards/:id/epics/:epicId"
element={
<ProtectedRoute>
<BoardDetailLayout>
<EpicDetail />
</BoardDetailLayout>
</ProtectedRoute>
}
/>
<Route
path="/boards/:id/epics/:epicId/edit"
element={
<ProtectedRoute>
<BoardDetailLayout>
<EditEpic />
</BoardDetailLayout>
</ProtectedRoute>
}
/>
<Route
path="/boards/:id/wikis"
element={
<ProtectedRoute>
<BoardDetailLayout>
<BoardWikis />
</BoardDetailLayout>
</ProtectedRoute>
}
/>
<Route
path="/boards/:id/wikis/new"
element={
<ProtectedRoute>
<BoardDetailLayout>
<CreateWiki />
</BoardDetailLayout>
</ProtectedRoute>
}
/>
<Route
path="/boards/:id/wikis/:wikiId"
element={
<ProtectedRoute>
<BoardDetailLayout>
<WikiDetail />
</BoardDetailLayout>
</ProtectedRoute>
}
/>
<Route
path="/boards/:id/wikis/:wikiId/edit"
element={
<ProtectedRoute>
<BoardDetailLayout>
<EditWiki />
</BoardDetailLayout>
</ProtectedRoute>
}
/>
@ -88,7 +181,9 @@ const App = () => {
path="/boards/:id/cards/:cardId"
element={
<ProtectedRoute>
<CardDetail />
<BoardDetailLayout>
<CardDetail />
</BoardDetailLayout>
</ProtectedRoute>
}
/>

View file

@ -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 (
<div className="relative">
<div className="pr-6">{children}</div>
{id && (
<div className="">
<BoardSidebar boardId={id} />
</div>
)}
</div>
);
};

View file

@ -0,0 +1,42 @@
import { Link, useLocation } from 'react-router-dom';
interface BoardSidebarProps {
boardId: string;
}
export function BoardSidebar({ boardId }: BoardSidebarProps) {
const location = useLocation();
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` },
];
return (
<div className="fixed right-0 top-1/2 -translate-y-1/2 z-40">
<div className="flex flex-col gap-2">
{menuItems.map((item) => {
const isActive = location.pathname === item.path;
return (
<Link
key={item.id}
to={item.path}
className={`
flex items-center gap-3 px-4 py-3 rounded-l-lg transition-all duration-200
${
isActive
? 'bg-blue-600 text-white'
: 'bg-gray-800 text-gray-300 hover:bg-gray-700 hover:text-white'
}
`}
>
<span className="text-xl">{item.icon}</span>
<span className="font-medium">{item.label}</span>
</Link>
);
})}
</div>
</div>
);
}

View file

@ -0,0 +1,163 @@
import { useFileAttachments } from '../hooks/useFileAttachments';
import Trash2Icon from './icons/Trash2Icon';
import FileIcon from './icons/FileIcon';
import DownloadIcon from './icons/DownloadIcon';
import EyeIcon from './icons/EyeIcon';
import { SecureImage } from './SecureImage';
interface CardAttachmentsProps {
cardId?: number;
}
export function CardAttachments({ cardId }: CardAttachmentsProps) {
const { attachments, uploadFile, deleteAttachment } = useFileAttachments(cardId);
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
try {
await uploadFile(file);
} catch {
// Error already handled in hook with toast
}
// Reset input
e.target.value = '';
};
const handleDelete = async (fileUuid: string, fileName: string) => {
if (!window.confirm(`Are you sure you want to delete "${fileName}"?`)) {
return;
}
await deleteAttachment(fileUuid);
};
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
};
const isImage = (mimeType: string) => mimeType.startsWith('image/');
return (
<div className="bg-gray-800 rounded-lg p-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold text-white flex items-center gap-2">
<span className="w-5 h-5">
<FileIcon />
</span>
Attachments
</h2>
<div className="flex items-center gap-2">
<span className="text-gray-400 text-sm">{attachments.length} files</span>
<label className="bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-lg transition-colors cursor-pointer flex items-center gap-2 text-sm">
Add Attachment
<input
type="file"
className="hidden"
onChange={handleFileUpload}
accept="image/*,.pdf"
/>
</label>
</div>
</div>
{attachments.length === 0 ? (
<div className="text-center py-8 text-gray-400">
<p>No attachments yet</p>
<p className="text-sm mt-2">Upload images or PDFs to this card</p>
</div>
) : (
<div className="space-y-3">
{attachments.map((attachment) => (
<div
key={attachment.uuid}
className="bg-gray-700 rounded-lg p-4 hover:bg-gray-650 transition-colors"
>
<div className="flex items-start gap-4">
{/* Thumbnail or file icon */}
<div className="flex-shrink-0">
{isImage(attachment.mime_type) && attachment.thumbnail_url ? (
<SecureImage
url={attachment.thumbnail_url}
alt={attachment.original_name}
className="w-16 h-16 object-cover rounded"
/>
) : (
<div className="w-16 h-16 bg-gray-600 rounded flex items-center justify-center">
<span className="w-8 h-8 text-gray-400">
<FileIcon />
</span>
</div>
)}
</div>
{/* File info */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<p className="text-white font-medium truncate">{attachment.original_name}</p>
<p className="text-gray-400 text-sm mt-1">
{formatFileSize(attachment.file_size)} {formatDate(attachment.created_at)}
</p>
</div>
{/* Action buttons */}
<div className="flex items-center gap-2 flex-shrink-0">
{isImage(attachment.mime_type) && attachment.view_url && (
<a
href={attachment.view_url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-400 hover:text-blue-300 transition-colors p-2"
title="View"
>
<span className="w-4 h-4">
<EyeIcon />
</span>
</a>
)}
{attachment.download_url && (
<a
href={attachment.download_url}
className="text-blue-400 hover:text-blue-300 transition-colors p-2"
title="Download"
>
<span className="w-4 h-4">
<DownloadIcon />
</span>
</a>
)}
<button
onClick={() => handleDelete(attachment.uuid, attachment.original_name)}
className="text-red-400 hover:text-red-300 transition-colors p-2"
title="Delete"
>
<span className="w-4 h-4">
<Trash2Icon />
</span>
</button>
</div>
</div>
</div>
</div>
</div>
))}
</div>
)}
</div>
);
}

View file

@ -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 (
<div className="bg-gray-800 rounded-lg overflow-hidden">
{/* Header */}
<div className="px-4 py-3 border-b border-gray-700 flex justify-between items-center">
<h2 className="text-lg font-semibold text-white">Epics</h2>
<span className="text-sm text-gray-400">{cardEpics.length} linked</span>
</div>
{/* Epics List */}
<div className="divide-y divide-gray-700 max-h-[400px] overflow-y-auto scrollbar-custom">
{cardEpics.length > 0 ? (
cardEpics.map((epic: EpicBasic) => (
<EpicListItem
key={epic.id}
epic={epic}
boardId={boardId}
onUnlink={() => handleRemoveEpic(epic.id, epic.name)}
/>
))
) : (
<div className="p-6 text-center">
<div className="text-4xl mb-2">📋</div>
<p className="text-gray-400 text-sm">No epics linked yet</p>
</div>
)}
</div>
{/* Add Epic Button */}
{availableEpics.length > 0 && (
<div className="px-4 py-3 border-t border-gray-700 relative">
<button
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
className="w-full flex items-center justify-center gap-2 text-gray-400 hover:text-white hover:bg-gray-700 px-3 py-2 rounded text-sm transition-colors"
>
<span className="w-4 h-4">
<PlusIcon />
</span>
{cardEpics.length > 0 ? 'Link another epic' : 'Link epic'}
</button>
{/* Dropdown */}
{isDropdownOpen && (
<div className="absolute bottom-full left-4 right-4 mb-2 bg-gray-700 rounded-lg shadow-lg border border-gray-600 max-h-[200px] overflow-y-auto scrollbar-custom z-10">
{availableEpics.map((epic: EpicBasic) => (
<button
key={epic.id}
onClick={() => handleAddEpic(epic.id)}
className="w-full text-left px-3 py-2 hover:bg-gray-600 transition-colors flex items-center gap-3 border-b border-gray-600 last:border-b-0"
>
<span
className="w-3 h-3 rounded-full flex-shrink-0"
style={{ backgroundColor: epic.color || '#3b82f6' }}
/>
<span className="text-white text-sm truncate flex-1">{epic.name}</span>
<span className="w-4 h-4 text-green-400 flex-shrink-0">
<CheckIcon />
</span>
</button>
))}
</div>
)}
</div>
)}
</div>
);
}
interface EpicListItemProps {
epic: EpicBasic;
boardId: number;
onUnlink: () => void;
}
function EpicListItem({ epic, boardId, onUnlink }: EpicListItemProps) {
const defaultColor = '#3b82f6';
const color = epic.color || defaultColor;
return (
<div className="group relative">
<Link
to={`/boards/${boardId}/epics/${epic.id}`}
className="block px-4 py-2.5 hover:bg-gray-750 transition-colors"
>
<div className="flex items-center gap-3">
{/* Color Indicator */}
<div className="w-2 h-2 rounded-full flex-shrink-0" style={{ backgroundColor: color }} />
{/* Epic Name */}
<h3 className="text-white text-sm font-medium truncate flex-1">{epic.name}</h3>
{/* Chevron indicator */}
<span className="w-4 h-4 text-gray-600 group-hover:text-gray-400 transition-colors flex-shrink-0">
<ChevronRightIcon />
</span>
</div>
</Link>
{/* Unlink Button (visible on hover) */}
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onUnlink();
}}
className="absolute right-2 top-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-100 transition-opacity bg-gray-800 hover:bg-red-900/50 text-gray-400 hover:text-red-400 p-1 rounded border border-gray-700 hover:border-red-800"
title="Unlink epic"
>
<span className="w-3 h-3">
<UnlinkIcon />
</span>
</button>
</div>
);
}

View file

@ -12,6 +12,7 @@ export function CardPreviewModal({ card, onClose }: CardPreviewModalProps) {
<div className="flex justify-between items-start mb-4">
<Link
to={`/boards/${card.board_id}/cards/${card.id}`}
onClick={onClose}
className="text-2xl font-bold text-blue-400 hover:text-blue-300 hover:underline transition-colors"
>
{card.name}
@ -24,6 +25,24 @@ export function CardPreviewModal({ card, onClose }: CardPreviewModalProps) {
</button>
</div>
{/* Epic */}
{card.epic && (
<div className="mb-4">
<Link
to={`/boards/${card.board_id}/epics/${card.epic.id}`}
onClick={onClose}
className="inline-flex items-center gap-2 px-3 py-1 rounded text-sm font-medium transition-colors hover:opacity-80"
style={{
backgroundColor: card.epic.color ? `${card.epic.color}33` : '#3b82f633',
color: card.epic.color || '#3b82f6',
border: `1px solid ${card.epic.color || '#3b82f6'}66`,
}}
>
<span>{card.epic.name}</span>
</Link>
</div>
)}
{/* Labels */}
{card.labels && card.labels.length > 0 && (
<div className="mb-4">

View file

@ -0,0 +1,37 @@
const ELEMENT_TAGS: Record<string, (el: HTMLElement) => Partial<any>> = {
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<string, () => Record<string, boolean>> = {
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 };

View file

@ -0,0 +1,5 @@
import { ReactNode } from 'react';
export const NarrowPageLayout = ({ children }: { children: ReactNode }) => {
return <div className="flex-1 p-8 mx-auto w-full max-w-7xl">{children}</div>;
};

View file

@ -3,41 +3,8 @@ import { useState } from 'react';
import { useApp } from '../context/AppContext';
import { useAuth } from '../hooks/useAuth';
import { TaskboardLogo } from './TaskboardLogo';
const MenuIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="3" y1="12" x2="21" y2="12"></line>
<line x1="3" y1="6" x2="21" y2="6"></line>
<line x1="3" y1="18" x2="21" y2="18"></line>
</svg>
);
const CloseIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
);
import MenuIcon from './icons/MenuIcon';
import CloseIcon from './icons/CloseIcon';
export function Navbar() {
const { user } = useApp();

View file

@ -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) => <SlateRenderElement {...props} />,
[]
);
const renderLeaf = useCallback((props: RenderLeafProps) => <Leaf {...props} />, []);
return (
<Slate editor={editor} initialValue={content}>
<div className="bg-gray-700 rounded-lg p-4 border border-gray-700">
<Editable
renderElement={renderElement}
renderLeaf={renderLeaf}
readOnly
placeholder="Enter some plain text..."
/>
</div>
</Slate>
);
};
export default RichTextContent;

View file

@ -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 (
<Button
active={isMarkActive(editor, format)}
onPointerDown={(event: any) => event.preventDefault()}
onClick={() => toggleMark(editor, format)}
>
<SlateIcon type={icon} />
</Button>
);
};
const RichTextEditor: React.FC<RichTextEditorProps> = ({
value,
onChange,
placeholder = 'Type something...',
readOnly = false,
className = '',
}) => {
const editor = useMemo(() => withHtml(withHistory(withReactPlugin(createEditor()))), []);
const renderElement = useCallback(
(props: RenderElementProps) => <SlateRenderElement {...props} />,
[]
);
// const renderElement = useCallback((props: any) => {
// switch (props.element.type) {
// case "block-quote":
// return <blockquote {...props.attributes}>{props.children}</blockquote>;
// case "bulleted-list":
// return <ul {...props.attributes}>{props.children}</ul>;
// case "list-item":
// return <li {...props.attributes}>{props.children}</li>;
// case "numbered-list":
// return <ol {...props.attributes}>{props.children}</ol>;
// default:
// return <p {...props.attributes}>{props.children}</p>;
// }
// }, []);
const renderLeaf = useCallback((props: RenderLeafProps) => <Leaf {...props} />, []);
return (
<div className={`bg-gray-700 rounded-lg border border-gray-700 ${className}`}>
<Slate editor={editor} initialValue={value} onChange={onChange}>
<Toolbar>
<MarkButton format="bold" icon="format_bold" />
<MarkButton format="italic" icon="format_italic" />
<MarkButton format="underline" icon="format_underlined" />
<MarkButton format="code" icon="code" />
<Separator />
<BlockButton format="heading-one" icon="looks_one" />
<BlockButton format="heading-two" icon="looks_two" />
<BlockButton format="block-quote" icon="format_quote" />
<BlockButton format="numbered-list" icon="format_list_numbered" />
<BlockButton format="bulleted-list" icon="format_list_bulleted" />
<Separator />
<BlockButton format="left" icon="format_align_left" />
<BlockButton format="center" icon="format_align_center" />
<BlockButton format="right" icon="format_align_right" />
<BlockButton format="justify" icon="format_align_justify" />
</Toolbar>
<Editable
renderElement={renderElement}
renderLeaf={renderLeaf}
placeholder={placeholder}
readOnly={readOnly}
className="min-h-[200px] px-4 pb-8 pt-1 text-gray-100 focus:outline-none"
spellCheck
/>
</Slate>
</div>
);
};
export default RichTextEditor;

View file

@ -0,0 +1,46 @@
import { useSecureImage } from '../hooks/useSecureImage';
interface SecureImageProps {
url: string;
alt: string;
className?: string;
fallback?: React.ReactNode;
}
export function SecureImage({ url, alt, className = '', fallback }: SecureImageProps) {
const { imageUrl, loading, error } = useSecureImage(url);
// Show loading state
if (loading) {
return <div className={`bg-gray-600 animate-pulse ${className}`}></div>;
}
// Show error or fallback
if (error || !imageUrl) {
return fallback ? (
<>{fallback}</>
) : (
<div className={`bg-gray-600 flex items-center justify-center ${className}`}>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-gray-400"
>
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<circle cx="8.5" cy="8.5" r="1.5"></circle>
<polyline points="21 15 16 10 5 21"></polyline>
</svg>
</div>
);
}
// Show image
return <img src={imageUrl} alt={alt} className={className} />;
}

View file

@ -0,0 +1,5 @@
import { ReactNode } from 'react';
export const WidePageLayout = ({ children }: { children: ReactNode }) => {
return <div className="flex-1 p-8 mx-auto w-full max-w-7xl">{children}</div>;
};

View file

@ -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<CustomText, 'text'>;
export type EmptyText = {
text: string;
};
export type RenderElementPropsFor<T> = RenderElementProps & {
element: T;
};
export type CustomEditor = BaseEditor &
ReactEditor &
HistoryEditor & {
nodeToDecorations?: Map<Element, Range[]>;
};
declare module 'slate' {
interface CustomTypes {
Editor: CustomEditor;
Element: CustomElement;
Text: CustomText;
Range: BaseRange & {
[key: string]: unknown;
};
}
}

View file

@ -0,0 +1,17 @@
const CheckIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="20 6 9 17 4 12"></polyline>
</svg>
);
export default CheckIcon;

View file

@ -0,0 +1,17 @@
const ChevronRightIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="9 18 15 12 9 6"></polyline>
</svg>
);
export default ChevronRightIcon;

View file

@ -0,0 +1,18 @@
const CloseIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
);
export default CloseIcon;

View file

@ -0,0 +1,19 @@
const DownloadIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
);
export default DownloadIcon;

View file

@ -1,4 +1,8 @@
const Edit2Icon = () => (
interface Edit2IconProps {
className?: string;
}
const Edit2Icon = ({ className = '' }: Edit2IconProps) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
@ -9,6 +13,7 @@ const Edit2Icon = () => (
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"></path>
</svg>

View file

@ -0,0 +1,18 @@
const EyeIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
<circle cx="12" cy="12" r="3"></circle>
</svg>
);
export default EyeIcon;

View file

@ -0,0 +1,18 @@
const FileIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"></path>
<polyline points="13 2 13 9 20 9"></polyline>
</svg>
);
export default FileIcon;

View file

@ -0,0 +1,19 @@
const MenuIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="3" y1="12" x2="21" y2="12"></line>
<line x1="3" y1="6" x2="21" y2="6"></line>
<line x1="3" y1="18" x2="21" y2="18"></line>
</svg>
);
export default MenuIcon;

View file

@ -1,4 +1,8 @@
const PlusIcon = () => (
interface PlusIconProps {
className?: string;
}
const PlusIcon = ({ className = '' }: PlusIconProps) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
@ -9,6 +13,7 @@ const PlusIcon = () => (
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>

View file

@ -1,4 +1,8 @@
const Trash2Icon = () => (
interface Trash2IconProps {
className?: string;
}
const Trash2Icon = ({ className = '' }: Trash2IconProps) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
@ -9,6 +13,7 @@ const Trash2Icon = () => (
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>

View file

@ -0,0 +1,18 @@
const UnlinkIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M18.36 6.64a9 9 0 1 1-12.73 0"></path>
<line x1="12" y1="2" x2="12" y2="12"></line>
</svg>
);
export default UnlinkIcon;

View file

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

View file

@ -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 (
<div
ref={setNodeRef}
@ -57,6 +61,22 @@ export function KanbanCard({ card, onOpenModal }: KanbanCardProps) {
}}
className="bg-gray-700 rounded-lg p-4 mb-3 cursor-pointer hover:bg-gray-600 transition-colors border border-gray-600 shadow-sm"
>
{/* Epic Badge */}
{hasEpic && (
<div className="mb-2">
<span
className="px-2 py-0.5 rounded text-xs font-medium"
style={{
backgroundColor: epic.color ? `${epic.color}33` : '#3b82f633',
color: epic.color || '#3b82f6',
border: `1px solid ${epic.color || '#3b82f6'}66`,
}}
>
{epic.name}
</span>
</div>
)}
{/* Labels */}
{hasLabels && (
<div className="flex flex-wrap gap-1 mb-2">

View file

@ -68,8 +68,8 @@ export function KanbanColumn({
};
return (
<div className="bg-gray-800 rounded-lg p-4 min-w-[300px] max-w-[300px] border border-gray-700 flex flex-col">
<div className="mb-4">
<div className="bg-gray-800 rounded-lg py-4 min-w-[300px] max-w-[300px] border border-gray-700 flex flex-col max-h-[calc(100vh-280px)]">
<div className="mb-4 px-4">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2 flex-1">
{/* Drag Handle Icon */}
@ -133,7 +133,7 @@ export function KanbanColumn({
>
<div
ref={setNodeRef}
className={`min-h-[200px] flex-1 transition-colors ${isOver ? 'bg-gray-750' : ''}`}
className={`min-h-[200px] flex-1 transition-colors ${isOver ? 'bg-gray-750' : ''} maxc-h-[calc(100vh-280px)] overflow-auto scrollbar-custom pl-4 pr-2`}
>
{cards.map((card) => (
<KanbanCard key={card.id} card={card} onOpenModal={() => onOpenCardModal(card)} />

View file

@ -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 = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M6 4h8a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"></path>
<path d="M6 12h9a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"></path>
</svg>
);
const FormatItalicIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="19" y1="4" x2="10" y2="4"></line>
<line x1="14" y1="20" x2="5" y2="20"></line>
<line x1="15" y1="4" x2="9" y2="20"></line>
</svg>
);
const FormatUnderlinedIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M6 3v7a6 6 0 0 0 6 6 6 6 0 0 0 6-6V3"></path>
<line x1="4" y1="21" x2="20" y2="21"></line>
</svg>
);
const CodeIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="16 18 22 12 16 6"></polyline>
<polyline points="8 6 2 12 8 18"></polyline>
</svg>
);
const LooksOneIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M4 12h8"></path>
<path d="M4 18V6"></path>
<path d="M12 18V6"></path>
</svg>
);
const LooksTwoIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M10 10a2 2 0 0 0-2-2H7a2 2 0 0 0-2 2v2"></path>
<path d="M17 10a2 2 0 0 0-2-2h-1a2 2 0 0 0-2 2v2"></path>
<path d="M8 16v-6"></path>
<path d="M15 16v-6"></path>
</svg>
);
const FormatQuoteIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3 21c3 0 7-1 7-8V5c0-1.25-.756-2.017-2-2H4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .008-1 1.031V20c0 1 0 1 1 1z"></path>
<path d="M15 21c3 0 7-1 7-8V5c0-1.25-.757-2.017-2-2h-4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .008-1 1.031V20c0 1 0 1 1 1z"></path>
</svg>
);
const FormatListNumberedIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="10" y1="6" x2="21" y2="6"></line>
<line x1="10" y1="12" x2="21" y2="12"></line>
<line x1="10" y1="18" x2="21" y2="18"></line>
<path d="M4 6h1v4"></path>
<path d="M4 10h2"></path>
<path d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1"></path>
</svg>
);
const FormatListBulletedIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="9" cy="12" r="1"></circle>
<circle cx="9" cy="5" r="1"></circle>
<circle cx="9" cy="19" r="1"></circle>
<line x1="3" y1="6" x2="3" y2="6"></line>
<line x1="3" y1="12" x2="3" y2="12"></line>
<line x1="3" y1="18" x2="3" y2="18"></line>
<line x1="21" y1="6" x2="21" y2="6"></line>
<line x1="21" y1="12" x2="21" y2="12"></line>
<line x1="21" y1="18" x2="21" y2="18"></line>
<line x1="15" y1="6" x2="15" y2="6"></line>
<line x1="15" y1="12" x2="15" y2="12"></line>
<line x1="15" y1="18" x2="15" y2="18"></line>
</svg>
);
const FormatAlignLeftIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="17" y1="10" x2="3" y2="10"></line>
<line x1="21" y1="6" x2="3" y2="6"></line>
<line x1="21" y1="14" x2="3" y2="14"></line>
<line x1="17" y1="18" x2="3" y2="18"></line>
</svg>
);
const FormatAlignCenterIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="10" x2="6" y2="10"></line>
<line x1="21" y1="6" x2="3" y2="6"></line>
<line x1="21" y1="14" x2="3" y2="14"></line>
<line x1="18" y1="18" x2="6" y2="18"></line>
</svg>
);
const FormatAlignRightIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="21" y1="10" x2="7" y2="10"></line>
<line x1="21" y1="6" x2="3" y2="6"></line>
<line x1="21" y1="14" x2="3" y2="14"></line>
<line x1="21" y1="18" x2="7" y2="18"></line>
</svg>
);
const FormatAlignJustifyIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="21" y1="10" x2="3" y2="10"></line>
<line x1="21" y1="6" x2="3" y2="6"></line>
<line x1="21" y1="14" x2="3" y2="14"></line>
<line x1="21" y1="18" x2="3" y2="18"></line>
</svg>
);
// --- Icon Mapping ---
const iconMap: Record<SlateIconType, React.FC> = {
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<HTMLButtonElement> {
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<HTMLButtonElement, ButtonProps>(
({ 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 (
<button
{...props}
type="button"
ref={ref}
className={`border-none bg-transparent cursor-pointer p-2 rounded transition-colors duration-150 ${colorClass} ${className ?? ''}`}
/>
);
}
);
Button.displayName = 'Button';
// --- SlateIcon Component ---
interface SlateIconProps {
type: SlateIconType;
className?: string;
}
export const SlateIcon = React.forwardRef<HTMLSpanElement, SlateIconProps>(
({ type, className, ...props }, ref) => {
const IconComponent = iconMap[type];
return (
<span
{...props}
ref={ref}
className={`inline-flex items-center justify-center ${className ?? ''}`}
>
<IconComponent />
</span>
);
}
);
SlateIcon.displayName = 'SlateIcon';
// Keep old Icon for backward compatibility, but mark as deprecated
export const Icon = React.forwardRef<HTMLSpanElement, React.HTMLAttributes<HTMLSpanElement>>(
({ className, ...props }, ref) => (
<span
{...props}
ref={ref}
// 'material-icons' is kept as it likely comes from a global CSS link
className={`material-icons text-[18px] align-text-bottom ${className ?? ''}`}
/>
)
);
Icon.displayName = 'Icon';
export const Instruction = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
{...props}
ref={ref}
className={`whitespace-pre-wrap my-0 mx-[-20px] mb-[10px] p-[10px] px-[20px] text-[14px] bg-[#f8f8e8] ${className ?? ''}`}
/>
)
);
Instruction.displayName = 'Instruction';
export const Menu = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
{...props}
data-test-id="menu"
ref={ref}
// Replaced inline-block + margin-left logic with flex + gap
className={`flex flex-wrap items-center gap-1 ${className ?? ''}`}
/>
)
);
Menu.displayName = 'Menu';
export const Separator = () => <div className="w-px h-6 bg-gray-600 mx-1" />;
export const Portal = ({ children }: { children?: ReactNode }) => {
return typeof document === 'object' ? ReactDOM.createPortal(children, document.body) : null;
};
export const Toolbar = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<Menu
{...props}
ref={ref}
className={`relative px-3 py-2 mx-0 mb-3 border-b border-gray-700 bg-gray-800/50 ${className ?? ''}`}
/>
)
);
Toolbar.displayName = 'Toolbar';
export const Leaf = ({ attributes, children, leaf }: RenderLeafProps) => {
if (leaf.bold) {
children = <strong className="font-bold">{children}</strong>;
}
if (leaf.code) {
children = (
<code className="bg-gray-900 text-blue-300 px-1 py-0.5 rounded font-mono text-sm border border-gray-700">
{children}
</code>
);
}
if (leaf.italic) {
children = <em className="italic">{children}</em>;
}
if (leaf.underline) {
children = <u className="underline">{children}</u>;
}
return <span {...attributes}>{children}</span>;
};
const isAlignElement = (element: CustomElement): element is CustomElementWithAlign => {
return 'align' in element;
};
export const SlateRenderElement = ({ attributes, children, element }: RenderElementProps) => {
switch (element.type) {
case 'block-quote':
return (
<blockquote
className="border-l-4 border-gray-500 pl-4 italic my-4 text-gray-300"
{...attributes}
>
{children}
</blockquote>
);
case 'code-block':
return (
<pre
className="bg-gray-900 text-gray-100 p-4 rounded-md overflow-x-auto my-4 font-mono text-sm border border-gray-700"
{...attributes}
>
<code>{children}</code>
</pre>
);
case 'bulleted-list':
return (
<ul className="list-disc pl-5 my-4 text-gray-200" {...attributes}>
{children}
</ul>
);
case 'heading-one':
return (
<h1 className="text-3xl font-bold my-4 text-gray-100" {...attributes}>
{children}
</h1>
);
case 'heading-two':
return (
<h2 className="text-2xl font-bold my-3 text-gray-100" {...attributes}>
{children}
</h2>
);
case 'heading-three':
return (
<h3 className="text-xl font-bold my-2 text-gray-100" {...attributes}>
{children}
</h3>
);
case 'list-item':
return (
<li className="my-1 text-gray-200" {...attributes}>
{children}
</li>
);
case 'numbered-list':
return (
<ol className="list-decimal pl-5 my-4 text-gray-200" {...attributes}>
{children}
</ol>
);
default:
return (
<div className="inline my-2 mr-2 leading-relaxed text-gray-200" {...attributes}>
{children}
</div>
);
}
};
const isBlockActive = (
editor: CustomEditor,
format: CustomElementFormat,
blockType: 'type' | 'align' = 'type'
) => {
const { selection } = editor;
if (!selection) return false;
const [match] = Array.from(
Editor.nodes(editor, {
at: Editor.unhangRange(editor, selection),
match: (n) => {
if (Node.isElement(n)) {
if (blockType === 'align' && isAlignElement(n)) {
return n.align === format;
}
return n.type === format;
}
return false;
},
})
);
return !!match;
};
const toggleBlock = (editor: CustomEditor, format: CustomElementFormat) => {
const isActive = isBlockActive(editor, format, isAlignType(format) ? 'align' : 'type');
const isList = isListType(format);
Transforms.unwrapNodes(editor, {
match: (n) => Node.isElement(n) && isListType(n.type) && !isAlignType(format),
split: true,
});
let newProperties: Partial<SlateElement>;
if (isAlignType(format)) {
newProperties = {
align: isActive ? undefined : format,
};
} else {
newProperties = {
type: isActive ? 'paragraph' : isList ? 'list-item' : format,
};
}
Transforms.setNodes<SlateElement>(editor, newProperties);
if (!isActive && isList) {
const block = { type: format, children: [] } as any;
Transforms.wrapNodes(editor, block);
}
};
interface BlockButtonProps {
format: CustomElementFormat;
icon: SlateIconType;
}
export const BlockButton = ({ format, icon }: BlockButtonProps) => {
const editor = useSlate();
return (
<Button
active={isBlockActive(editor, format, isAlignType(format) ? 'align' : 'type')}
onPointerDown={(event: any) => event.preventDefault()}
onClick={() => toggleBlock(editor, format)}
data-test-id={`block-button-${format}`}
>
<SlateIcon type={icon} />
</Button>
);
};
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;
};

View file

@ -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<ProductData[]> => {
const response = await api.get<ProductData[]>('/products');
return response.data;
},
getProduct: async (id: string): Promise<ProductData> => {
const response = await api.get<ProductData>(`/products/${id}`);
return response.data;
},
createProduct: async (productData: Omit<ProductData, 'id'>): Promise<ProductData> => {
const response = await api.post<ProductData>('/products', productData);
return response.data;
},
updateProduct: async (
id: string,
productData: Partial<ProductData>
): Promise<ProductData> => {
const response = await api.put<ProductData>(`/products/${id}`, productData);
return response.data;
},
deleteProduct: async (id: string): Promise<void> => {
await api.delete(`/products/${id}`);
},
// Orders
getOrders: async (): Promise<OrderData[]> => {
const response = await api.get<OrderData[]>('/orders');
return response.data;
},
getOrder: async (id: string): Promise<OrderData> => {
const response = await api.get<OrderData>(`/orders/${id}`);
return response.data;
},
createOrder: async (orderData: Omit<OrderData, 'id'>): Promise<OrderData> => {
const response = await api.post<OrderData>('/orders', orderData);
return response.data;
},
// Boards
getBoards: async (): Promise<Board[]> => {
const response = await api.get<Board[]>('/boards');
@ -267,6 +229,87 @@ export function useApi() {
deleteComment: async (id: number): Promise<void> => {
await api.delete(`/comments/${id}`);
},
// File Attachments
getCardAttachments: async (cardId: number): Promise<any> => {
const response = await api.get(`/cards/${cardId}/attachments`);
return response.data;
},
uploadCardAttachment: async (
cardId: number,
file: File,
entityType: string = 'Card'
): Promise<any> => {
const formData = new FormData();
formData.append('file', file);
formData.append('entity_type', entityType);
formData.append('entity_id', cardId.toString());
const response = await api.post(`/cards/${cardId}/attachments`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return response.data;
},
deleteFile: async (fileUuid: string): Promise<void> => {
await api.delete(`/files/${fileUuid}`);
},
getSecureImage: async (url: string): Promise<Blob> => {
const response = await api.get(url, {
responseType: 'blob',
});
return response.data;
},
// Epics
getEpics: async (boardId: number): Promise<any> => {
const response = await api.get(`/boards/${boardId}/epics`);
return response.data;
},
createEpic: async (boardId: number, epicData: any): Promise<any> => {
const response = await api.post(`/boards/${boardId}/epics`, epicData);
return response.data;
},
getEpic: async (epicId: number): Promise<any> => {
const response = await api.get(`/epics/${epicId}`);
return response.data;
},
updateEpic: async (epicId: number, epicData: any): Promise<any> => {
const response = await api.put(`/epics/${epicId}`, epicData);
return response.data;
},
deleteEpic: async (epicId: number): Promise<void> => {
await api.delete(`/epics/${epicId}`);
},
addEpicToCard: async (cardId: number, epicId: number): Promise<any> => {
const response = await api.post(`/cards/${cardId}/epics`, { epic_id: epicId });
return response.data;
},
removeEpicFromCard: async (cardId: number, epicId: number): Promise<void> => {
await api.delete(`/cards/${cardId}/epics/${epicId}`);
},
// Wikis
getWikis: async (boardId: number): Promise<any> => {
const response = await api.get(`/boards/${boardId}/wikis`);
return response.data;
},
createWiki: async (boardId: number, wikiData: any): Promise<any> => {
const response = await api.post(`/boards/${boardId}/wikis`, wikiData);
return response.data;
},
getWiki: async (wikiId: number): Promise<any> => {
const response = await api.get(`/wikis/${wikiId}`);
return response.data;
},
updateWiki: async (wikiId: number, wikiData: any): Promise<any> => {
const response = await api.put(`/wikis/${wikiId}`, wikiData);
return response.data;
},
deleteWiki: async (wikiId: number): Promise<void> => {
await api.delete(`/wikis/${wikiId}`);
},
}),
[]
);

View file

@ -45,7 +45,6 @@ export function useAuth() {
} catch (err: any) {
const errorMessage =
err.response?.data.error || err.message || 'Login failed. Please try again.';
// debugger
// Show error toast
addNotification({
type: 'error',
@ -76,7 +75,6 @@ export function useAuth() {
};
// Store in localStorage first
// debugger
localStorage.setItem('token', response.access_token);
localStorage.setItem('user', JSON.stringify(user));

View file

@ -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<EpicBasic[]>([]);
const [error, setError] = useState<Error | null>(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,
};
}

View file

@ -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<Epic | null>(null);
const [error, setError] = useState<Error | null>(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;

View file

@ -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<Epic[]>([]);
const [error, setError] = useState<Error | null>(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;

View file

@ -0,0 +1,129 @@
import { useState, useEffect, useCallback } from 'react';
import { useApi } from './useApi';
import { useLoader } from '../context/loaders/useLoader';
import { useToast } from '../context/toasts/useToast';
import { FileAttachment } from '../types/kanban';
export function useFileAttachments(cardId?: number) {
const [attachments, setAttachments] = useState<FileAttachment[]>([]);
const [error, setError] = useState<Error | null>(null);
const { getCardAttachments, uploadCardAttachment, deleteFile } = useApi();
const { withLoader } = useLoader();
const { addNotification } = useToast();
const fetchAttachments = useCallback(async () => {
if (!cardId) {
return [];
}
try {
setError(null);
const data = await withLoader(() => getCardAttachments(cardId), 'Loading attachments...');
setAttachments(data.files || []);
return data.files || [];
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to load attachments';
setError(err instanceof Error ? err : new Error(errorMessage));
addNotification({
type: 'error',
title: 'Error Loading Attachments',
message: errorMessage,
duration: 5000,
});
return [];
}
}, [getCardAttachments, cardId, withLoader, addNotification]);
const uploadFile = useCallback(
async (file: File) => {
if (!cardId) {
return null;
}
try {
setError(null);
// Validate file size (10MB for images, 50MB for documents)
const maxSize = file.type.startsWith('image/') ? 10 * 1024 * 1024 : 50 * 1024 * 1024;
if (file.size > maxSize) {
const maxSizeMB = file.type.startsWith('image/') ? '10MB' : '50MB';
throw new Error(`File size exceeds ${maxSizeMB} limit`);
}
const data = await withLoader(
() => uploadCardAttachment(cardId, file, 'Card'),
'Uploading file...'
);
// Add new attachment to state
setAttachments((prev) => [...prev, data]);
addNotification({
type: 'success',
title: 'File Uploaded',
message: `Successfully uploaded ${file.name}`,
duration: 3000,
});
return data;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to upload file';
setError(err instanceof Error ? err : new Error(errorMessage));
addNotification({
type: 'error',
title: 'Upload Failed',
message: errorMessage,
duration: 5000,
});
throw err;
}
},
[uploadCardAttachment, cardId, withLoader, addNotification]
);
const deleteAttachment = useCallback(
async (fileUuid: string) => {
try {
setError(null);
await withLoader(() => deleteFile(fileUuid), 'Deleting file...');
// Remove attachment from state
setAttachments((prev) => prev.filter((a) => a.uuid !== fileUuid));
addNotification({
type: 'success',
title: 'File Deleted',
message: 'File deleted successfully',
duration: 3000,
});
return true;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to delete file';
setError(err instanceof Error ? err : new Error(errorMessage));
addNotification({
type: 'error',
title: 'Delete Failed',
message: errorMessage,
duration: 5000,
});
throw err;
}
},
[deleteFile, withLoader, addNotification]
);
useEffect(() => {
if (cardId) {
fetchAttachments();
}
}, [cardId, fetchAttachments]);
return {
attachments,
error, // For debugging, not for UI display
loading: false, // Loading is handled by global loader
refetch: fetchAttachments,
uploadFile,
deleteAttachment,
};
}

View file

@ -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<ProductData[]>([]);
const [error, setError] = useState<string | null>(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,
};
}

View file

@ -0,0 +1,51 @@
import { useState, useEffect, useCallback } from 'react';
import { useApi } from './useApi';
interface UseSecureImageReturn {
imageUrl: string | null;
loading: boolean;
error: Error | null;
}
export function useSecureImage(url: string): UseSecureImageReturn {
const [imageUrl, setImageUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const { getSecureImage } = useApi();
const fetchImage = useCallback(async () => {
if (!url) {
setLoading(false);
return;
}
try {
setLoading(true);
setError(null);
// Fetch image with authentication
const blob = await getSecureImage(url);
// Create blob URL
const blobUrl = URL.createObjectURL(blob);
setImageUrl(blobUrl);
} catch (err) {
console.error('Error fetching image:', err);
const errorMessage = err instanceof Error ? err.message : 'Failed to load image';
setError(new Error(errorMessage));
} finally {
setLoading(false);
}
}, [url, getSecureImage]);
useEffect(() => {
fetchImage();
}, [fetchImage]);
return {
imageUrl,
loading,
error, // For debugging
};
}

View file

@ -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<Wiki | null>(null);
const [error, setError] = useState<Error | null>(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;

View file

@ -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<Wiki[]>([]);
const [error, setError] = useState<Error | null>(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;

Some files were not shown because too many files have changed in this diff Show more