Compare commits

...

10 commits

122 changed files with 11340 additions and 1696 deletions

View file

@ -17,6 +17,16 @@ GRAFANA_PASSWORD=change-this-password-in-production
CELERY_BROKER_URL=redis://redis:6379/0 CELERY_BROKER_URL=redis://redis:6379/0
CELERY_RESULT_BACKEND=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 # Optional: External Services
# REDIS_URL=redis://localhost:6379/0 # REDIS_URL=redis://localhost:6379/0
# SMTP_HOST=smtp.gmail.com # SMTP_HOST=smtp.gmail.com

2
.gitignore vendored
View file

@ -84,3 +84,5 @@ htmlcov/
celerybeat-schedule celerybeat-schedule
backend/app/static backend/app/static
.clinerules

View file

@ -21,6 +21,10 @@ install: ## Install dependencies
@echo "Installing frontend dependencies..." @echo "Installing frontend dependencies..."
cd frontend && npm install 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) dev-services: ## Start development services (postgres & redis only)
@echo "Starting development services (postgres & redis)..." @echo "Starting development services (postgres & redis)..."
docker compose -f docker-compose.dev.yml up -d docker compose -f docker-compose.dev.yml up -d
@ -62,6 +66,9 @@ restart: ## Restart all services
logs: ## Show logs from all services logs: ## Show logs from all services
docker compose logs -f docker compose logs -f
dev-logs: ## Show logs from all services
docker compose -f docker-compose.dev.yml logs
test: ## Run all tests test: ## Run all tests
@echo "Running backend tests..." @echo "Running backend tests..."
cd backend && . venv/bin/activate && pytest cd backend && . venv/bin/activate && pytest
@ -129,7 +136,7 @@ format-frontend: ## Format frontend code only
@echo "Formatting frontend..." @echo "Formatting frontend..."
cd frontend && npx prettier --write "src/**/*.{js,jsx,ts,tsx,css}" 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 cd backend && . venv/bin/activate && flask db upgrade
shell: ## Open Flask shell shell: ## Open Flask shell

View file

@ -9,3 +9,8 @@ TEST_DATABASE_URL=sqlite:///test.db
# Celery Configuration # Celery Configuration
CELERY_BROKER_URL=redis://localhost:6379/0 CELERY_BROKER_URL=redis://localhost:6379/0
CELERY_RESULT_BACKEND=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", "*")}} 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 # Initialize Celery
from app.celery import init_celery from app.celery import init_celery

View file

@ -39,6 +39,18 @@ class Config:
"pool_pre_ping": True, # Verify connections before using "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): class DevelopmentConfig(Config):
"""Development configuration""" """Development configuration"""
@ -46,11 +58,31 @@ class DevelopmentConfig(Config):
DEBUG = True DEBUG = True
SQLALCHEMY_DATABASE_URI = os.environ["DATABASE_URL"] 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): class TestingConfig(Config):
"""Testing configuration""" """Testing configuration"""
TESTING = True TESTING = True
USE_MOCK_STORAGE = True # Use in-memory mock storage for tests
SQLALCHEMY_DATABASE_URI = os.environ.get("TEST_DATABASE_URL") SQLALCHEMY_DATABASE_URI = os.environ.get("TEST_DATABASE_URL")
WTF_CSRF_ENABLED = False WTF_CSRF_ENABLED = False
@ -63,6 +95,31 @@ class TestingConfig(Config):
"pool_pre_ping": True, # Verify connections before using "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): class ProductionConfig(Config):
"""Production configuration""" """Production configuration"""
@ -75,6 +132,31 @@ class ProductionConfig(Config):
SESSION_COOKIE_HTTPONLY = True SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = "Lax" 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 = { config_by_name = {
"dev": DevelopmentConfig, "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 functools import wraps
from flask import abort, g from flask import abort
from flask_jwt_extended import get_jwt_identity
from app.models import Board, Card, CheckItem, Checklist, Comment, List from app.decorators.decorators import get_current_user_id
from app.models import (Board, Card, CheckItem, Checklist, Comment,
FileAttachment, 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
def load_board_owned(f): def load_board_owned(f):
@ -157,3 +150,28 @@ def load_comment_owned(f):
return f(*args, **kwargs) return f(*args, **kwargs)
return decorated_function 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.check_item import CheckItem
from app.models.checklist import Checklist from app.models.checklist import Checklist
from app.models.comment import Comment 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.label import Label
from app.models.list_model import List 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.user import User
from app.models.wiki import Wiki, wiki_entity_links
__all__ = [ __all__ = [
"User", "User",
"Product",
"Order",
"OrderItem",
"Board", "Board",
"List", "List",
"Card", "Card",
@ -23,4 +21,8 @@ __all__ = [
"Checklist", "Checklist",
"CheckItem", "CheckItem",
"Comment", "Comment",
"FileAttachment",
"Epic",
"Wiki",
"wiki_entity_links",
] ]

View file

@ -32,6 +32,9 @@ class Card(db.Model):
nullable=False, nullable=False,
index=True, index=True,
) )
epic_id = db.Column(
db.Integer, db.ForeignKey("epics.id", ondelete="SET NULL"), index=True
)
# Timestamps # Timestamps
date_last_activity = db.Column(db.DateTime) date_last_activity = db.Column(db.DateTime)
@ -57,6 +60,14 @@ class Card(db.Model):
comments = db.relationship( comments = db.relationship(
"Comment", backref="card", cascade="all, delete-orphan", lazy="dynamic" "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): def to_dict(self):
"""Convert card to dictionary""" """Convert card to dictionary"""
@ -71,6 +82,7 @@ class Card(db.Model):
"id_short": self.id_short, "id_short": self.id_short,
"board_id": self.board_id, "board_id": self.board_id,
"list_id": self.list_id, "list_id": self.list_id,
"epic_id": self.epic_id,
"date_last_activity": self.date_last_activity.isoformat() "date_last_activity": self.date_last_activity.isoformat()
if self.date_last_activity if self.date_last_activity
else None, else None,
@ -83,3 +95,130 @@ class Card(db.Model):
def __repr__(self): def __repr__(self):
return f"<Card {self.name}>" 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), 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): def to_dict(self):
"""Convert comment to dictionary""" """Convert comment to dictionary"""
return { 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 # Relationships
orders = db.relationship("Order", back_populates="user", lazy="dynamic")
boards = db.relationship( boards = db.relationship(
"Board", backref="user", cascade="all, delete-orphan", lazy="dynamic" "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 import Blueprint, jsonify, request
from flask_jwt_extended import (create_access_token, create_refresh_token, from flask_jwt_extended import (create_access_token, create_refresh_token,
get_jwt_identity, jwt_required) get_jwt_identity, jwt_required)
from pydantic import ValidationError
from app import db from app import db
from app.celery import celery from app.models import User
from app.models import Order, OrderItem, Product, User
from app.schemas import ProductCreateRequest, ProductResponse
api_bp = Blueprint("api", __name__) api_bp = Blueprint("api", __name__)
@ -79,333 +76,3 @@ def get_current_user():
return jsonify({"error": "User not found"}), 404 return jsonify({"error": "User not found"}), 404
return jsonify(user.to_dict()), 200 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 # Import all route modules to register their routes to this blueprint
# fmt: off # fmt: off
from . import (boards, cards, checklists, comments, labels, # noqa: F401 E402 from . import (boards, cards, checklists, comments, epics, # noqa: F401 E402
lists) files, labels, lists, wikis)
# fmt: on # fmt: on

View file

@ -5,7 +5,8 @@ from flask_jwt_extended import jwt_required
from flask_pydantic import validate from flask_pydantic import validate
from app import db 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.models import Board, Card, CardLabel, Label, List
from app.schemas import (BoardCreateRequest, BoardResponse, from app.schemas import (BoardCreateRequest, BoardResponse,
BoardWithDetailsResponse) BoardWithDetailsResponse)
@ -64,6 +65,12 @@ def get_board(board_id, board):
for checklist in card.checklists.all() 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) cards_data.append(card_dict)
lists_data.append({**lst.to_dict(), "cards": cards_data}) 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 comment_dict["user"] = user.to_dict() if user else None
card_dict["comments"].append(comment_dict) 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) response = CardWithDetailsResponse(**card_dict)
return response.model_dump(), 200 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.checklist import ChecklistCreateRequest, ChecklistResponse
from app.schemas.comment import (CommentCreateRequest, CommentResponse, from app.schemas.comment import (CommentCreateRequest, CommentResponse,
CommentWithUserResponse) 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.label import LabelCreateRequest, LabelResponse
from app.schemas.list_model import ListCreateRequest, ListResponse 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__ = [ __all__ = [
# Product schemas # Product schemas
"ProductCreateRequest",
"ProductResponse",
# Board schemas # Board schemas
"BoardCreateRequest", "BoardCreateRequest",
"BoardResponse", "BoardResponse",
@ -36,7 +41,25 @@ __all__ = [
# Comment schemas # Comment schemas
"CommentCreateRequest", "CommentCreateRequest",
"CommentResponse", "CommentResponse",
# File Attachment schemas
"FileAttachmentWithUrlResponse",
"FileDeleteResponse",
"FileUploadRequest",
"MultipleFilesResponse",
"ValidationError",
# Composite schemas
"BoardWithDetailsResponse", "BoardWithDetailsResponse",
"CardWithDetailsResponse", "CardWithDetailsResponse",
"CommentWithUserResponse", "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, "pos": 65535.0,
"list_id": 1, "list_id": 1,
"board_id": 1, "board_id": 1,
"epic_id": 1,
"due": "2024-12-31T23:59:59", "due": "2024-12-31T23:59:59",
"due_complete": False, "due_complete": False,
"badges": {"votes": 0}, "badges": {"votes": 0},
@ -64,6 +65,7 @@ class CardResponse(BaseModel):
pos: float pos: float
list_id: int list_id: int
board_id: int board_id: int
epic_id: Optional[int]
due: Optional[datetime] = None due: Optional[datetime] = None
due_complete: bool due_complete: bool
badges: Optional[Dict[str, Any]] = None badges: Optional[Dict[str, Any]] = None
@ -115,3 +117,4 @@ class CardWithDetailsResponse(BaseModel):
labels: List[Dict[str, Any]] = Field(default_factory=list) labels: List[Dict[str, Any]] = Field(default_factory=list)
checklists: 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) 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,6 +10,8 @@ Werkzeug==3.0.1
SQLAlchemy==2.0.23 SQLAlchemy==2.0.23
celery[redis]==5.3.6 celery[redis]==5.3.6
pydantic==2.5.3 pydantic==2.5.3
minio==7.2.0
Pillow==10.1.0
pytest==7.4.3 pytest==7.4.3
pytest-flask==1.3.0 pytest-flask==1.3.0
pytest-cov==4.1.0 pytest-cov==4.1.0

View file

@ -1,11 +1,14 @@
"""Pytest configuration and fixtures""" """Pytest configuration and fixtures"""
import logging import logging
import time
from io import BytesIO
import pytest import pytest
from faker import Faker from faker import Faker
from PIL import Image
from app import create_app, db 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__) log = logging.getLogger(__name__)
fake = Faker() fake = Faker()
@ -113,7 +116,7 @@ def regular_user(db_session):
"""Create a regular user for testing""" """Create a regular user for testing"""
user = User( user = User(
email=fake.email(), email=fake.email(),
username=fake.user_name(), username=f"{fake.user_name()}_{int(time.time() * 1000)}",
first_name=fake.first_name(), first_name=fake.first_name(),
last_name=fake.last_name(), last_name=fake.last_name(),
is_admin=False, is_admin=False,
@ -142,39 +145,6 @@ def inactive_user(db_session):
return user 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 @pytest.fixture
def auth_headers(client, regular_user): def auth_headers(client, regular_user):
"""Get authentication headers for a regular user""" """Get authentication headers for a regular user"""
@ -200,28 +170,89 @@ def admin_headers(client, admin_user):
return {"Authorization": f"Bearer {token}"} return {"Authorization": f"Bearer {token}"}
# ============ File Upload Fixtures ============
@pytest.fixture @pytest.fixture
def order(db_session, regular_user, products): def test_image_file():
print("-----order-created------") """Create a test image file as BytesIO"""
"""Create an order for testing""" # Create a simple red PNG image using PIL
order = Order( img = Image.new("RGB", (10, 10), color="red")
user_id=regular_user.id, total_amount=0.0, shipping_address=fake.address() 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.add(board)
db_session.flush()
total_amount = 0
for i, product in enumerate(products[:2]):
quantity = fake.pyint(min_value=1, max_value=5)
order_item = OrderItem(
order_id=order.id,
product_id=product.id,
quantity=quantity,
price=product.price,
)
total_amount += float(product.price) * quantity
db_session.add(order_item)
order.total_amount = total_amount
db_session.commit() 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""" """Test models"""
from decimal import Decimal
import pytest import pytest
from app.models import Order, OrderItem, Product, User from app.models import User
class TestUserModel: class TestUserModel:
@ -69,132 +68,3 @@ class TestUserModel:
db_session.commit() db_session.commit()
assert repr(user) == "<User testuser3>" 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""" """Test getting current user without authentication"""
response = client.get("/api/users/me") response = client.get("/api/users/me")
assert response.status_code == 401 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" - "6381:6379"
restart: unless-stopped 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: volumes:
postgres-dev-data: postgres-dev-data:
minio-data:
networks: networks:
kanban-app-network: kanban-app-network:

View file

@ -55,6 +55,27 @@ services:
- crafting-shop-network - crafting-shop-network
restart: unless-stopped 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: celery_worker:
build: build:
context: ./backend context: ./backend
@ -163,6 +184,7 @@ volumes:
grafana-data: grafana-data:
backend-data: backend-data:
celery-beat-data: celery-beat-data:
minio-data:
networks: networks:
crafting-shop-network: 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 ### 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 - **NEVER** use icon libraries like lucide-react, react-icons, or font-awesome
- Create reusable SVG icon components when needed - Each icon should be a separate file with a default export
- SVGs should be defined as functional components - Import icons from the icons folder
```jsx ```jsx
// ✅ CORRECT - Inline SVG as a component // ✅ CORRECT - Icon component in frontend/src/components/icons/TrashIcon.tsx
const TrashIcon = () => ( 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"> <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> <polyline points="3 6 5 6 21 6"></polyline>
@ -386,6 +386,11 @@ const TrashIcon = () => (
</svg> </svg>
); );
export default TrashIcon;
// ✅ CORRECT - Using icon component
import TrashIcon from "../components/icons/TrashIcon";
function DeleteButton() { function DeleteButton() {
return ( return (
<button className="flex items-center gap-2"> <button className="flex items-center gap-2">
@ -418,14 +423,31 @@ function DeleteButton() {
</button> </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 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 3. **Performance**: No runtime overhead from library wrappers
4. **Consistency**: All icons follow to same pattern and styling 4. **Consistency**: All icons follow the same pattern and are organized in one place
5. **TypeScript support**: Full type safety without any issues 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 ## Routing Rules
@ -802,7 +824,7 @@ export function useNewContext() {
**NEVER** hardcode API URLs **NEVER** hardcode API URLs
**NEVER** skip PropTypes validation **NEVER** skip PropTypes validation
**NEVER** use `<a>` for navigation (use `<Link>`) **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 ## Checklist Before Committing

View file

@ -17,12 +17,16 @@
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-hook-form": "^7.71.2", "react-hook-form": "^7.71.2",
"react-router-dom": "^6.20.1", "react-router-dom": "^6.20.1",
"slate": "^0.123.0",
"slate-history": "^0.113.1",
"slate-react": "^0.123.0",
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {
"@testing-library/jest-dom": "^6.1.5", "@testing-library/jest-dom": "^6.1.5",
"@testing-library/react": "^14.1.2", "@testing-library/react": "^14.1.2",
"@testing-library/user-event": "^14.5.1", "@testing-library/user-event": "^14.5.1",
"@types/node": "^25.5.0",
"@types/react": "^18.3.28", "@types/react": "^18.3.28",
"@types/react-dom": "^18.3.7", "@types/react-dom": "^18.3.7",
"@typescript-eslint/eslint-plugin": "^8.56.1", "@typescript-eslint/eslint-plugin": "^8.56.1",
@ -1054,6 +1058,11 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@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": { "node_modules/@nodelib/fs.scandir": {
"version": "2.1.5", "version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@ -1614,6 +1623,15 @@
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"dev": true "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": { "node_modules/@types/prop-types": {
"version": "15.7.15", "version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
@ -2766,6 +2784,11 @@
"node": ">= 6" "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": { "node_modules/concat-map": {
"version": "0.0.1", "version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "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": "^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": { "node_modules/dlv": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
@ -4486,6 +4521,11 @@
"node": ">=0.10.0" "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": { "node_modules/is-map": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz",
@ -4544,6 +4584,14 @@
"node": ">=8" "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": { "node_modules/is-potential-custom-element-name": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", "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" "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": { "node_modules/lodash.merge": {
"version": "4.6.2", "version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@ -6210,6 +6263,14 @@
"loose-envify": "^1.1.0" "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": { "node_modules/semver": {
"version": "6.3.1", "version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
@ -6390,6 +6451,59 @@
"node": ">= 10" "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": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@ -6734,6 +6848,11 @@
"node": ">=0.8" "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": { "node_modules/tinybench": {
"version": "2.9.0", "version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
@ -7018,6 +7137,12 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/universalify": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz",

View file

@ -23,12 +23,16 @@
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-hook-form": "^7.71.2", "react-hook-form": "^7.71.2",
"react-router-dom": "^6.20.1", "react-router-dom": "^6.20.1",
"slate": "^0.123.0",
"slate-history": "^0.113.1",
"slate-react": "^0.123.0",
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {
"@testing-library/jest-dom": "^6.1.5", "@testing-library/jest-dom": "^6.1.5",
"@testing-library/react": "^14.1.2", "@testing-library/react": "^14.1.2",
"@testing-library/user-event": "^14.5.1", "@testing-library/user-event": "^14.5.1",
"@types/node": "^25.5.0",
"@types/react": "^18.3.28", "@types/react": "^18.3.28",
"@types/react-dom": "^18.3.7", "@types/react-dom": "^18.3.7",
"@typescript-eslint/eslint-plugin": "^8.56.1", "@typescript-eslint/eslint-plugin": "^8.56.1",

View file

@ -1,4 +1,6 @@
import { Routes, Route, Navigate } from 'react-router-dom'; import { Routes, Route, Navigate } from 'react-router-dom';
import { useEffect, useState } from 'react';
import { useApp } from './context/AppContext';
import { ModalProvider } from './context/modals/useModal'; import { ModalProvider } from './context/modals/useModal';
import { ModalRoot } from './context/modals/ModalRoot'; import { ModalRoot } from './context/modals/ModalRoot';
import { ToastProvider } from './context/toasts/useToast'; import { ToastProvider } from './context/toasts/useToast';
@ -14,18 +16,46 @@ import { Boards } from './pages/Boards';
import { BoardCreate } from './pages/BoardCreate'; import { BoardCreate } from './pages/BoardCreate';
import { BoardEdit } from './pages/BoardEdit'; import { BoardEdit } from './pages/BoardEdit';
import { BoardDetail } from './pages/BoardDetail'; 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 { CardDetail } from './pages/CardDetail';
import { BoardDetailLayout } from './components/BoardDetailLayout';
const App = () => { const App = () => {
const { token } = useApp();
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
useEffect(() => {
setIsAuthenticated(!!token);
}, [token]);
if (isAuthenticated === null) {
return null;
}
return ( return (
<LoaderProvider> <LoaderProvider>
<ToastProvider> <ToastProvider>
<ModalProvider> <ModalProvider>
<div className="min-h-screen bg-gray-900 text-gray-100"> <div className="min-h-screen bg-gray-900 text-gray-100">
<Navbar /> <Navbar />
<main className="flex-1 p-8 max-w-7xl mx-auto w-full"> <main>
<Routes> <Routes>
<Route path="/" element={<Navigate to="/boards" replace />} /> <Route
path="/"
element={
isAuthenticated ? (
<Navigate to="/boards" replace />
) : (
<Navigate to="/home" replace />
)
}
/>
<Route path="/home" element={<Home />} /> <Route path="/home" element={<Home />} />
<Route path="/login" element={<Login />} /> <Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} /> <Route path="/register" element={<Register />} />
@ -51,7 +81,9 @@ const App = () => {
path="/boards/:id/edit" path="/boards/:id/edit"
element={ element={
<ProtectedRoute> <ProtectedRoute>
<BoardEdit /> <BoardDetailLayout>
<BoardEdit />
</BoardDetailLayout>
</ProtectedRoute> </ProtectedRoute>
} }
/> />
@ -59,7 +91,89 @@ const App = () => {
path="/boards/:id" path="/boards/:id"
element={ element={
<ProtectedRoute> <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> </ProtectedRoute>
} }
/> />
@ -67,7 +181,9 @@ const App = () => {
path="/boards/:id/cards/:cardId" path="/boards/:id/cards/:cardId"
element={ element={
<ProtectedRoute> <ProtectedRoute>
<CardDetail /> <BoardDetailLayout>
<CardDetail />
</BoardDetailLayout>
</ProtectedRoute> </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"> <div className="flex justify-between items-start mb-4">
<Link <Link
to={`/boards/${card.board_id}/cards/${card.id}`} 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" className="text-2xl font-bold text-blue-400 hover:text-blue-300 hover:underline transition-colors"
> >
{card.name} {card.name}
@ -24,6 +25,24 @@ export function CardPreviewModal({ card, onClose }: CardPreviewModalProps) {
</button> </button>
</div> </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 */} {/* Labels */}
{card.labels && card.labels.length > 0 && ( {card.labels && card.labels.length > 0 && (
<div className="mb-4"> <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

@ -1,27 +1,31 @@
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useState } from 'react';
import { useApp } from '../context/AppContext'; import { useApp } from '../context/AppContext';
import { useAuth } from '../hooks/useAuth'; import { useAuth } from '../hooks/useAuth';
import { TaskboardLogo } from './TaskboardLogo'; import { TaskboardLogo } from './TaskboardLogo';
import MenuIcon from './icons/MenuIcon';
import CloseIcon from './icons/CloseIcon';
export function Navbar() { export function Navbar() {
const { user } = useApp(); const { user } = useApp();
const { logout } = useAuth(); const { logout } = useAuth();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
return ( return (
<nav className="bg-gray-800 border-b border-gray-700 shadow-md"> <nav className="bg-gray-800 border-b border-gray-700 shadow-md">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16"> <div className="flex items-center justify-between h-16">
<div className="flex items-center gap-3"> <div className="flex items-center">
<Link to="/boards" className="hover:opacity-80 transition-opacity"> <Link to="/boards" className="hover:opacity-80 transition-opacity flex-shrink-0">
<TaskboardLogo className="h-8 w-auto" /> <TaskboardLogo className="h-8 w-auto" />
</Link> </Link>
<Link <Link
to="/boards" to="/boards"
className="text-xl font-bold text-white hover:text-blue-400 transition-colors" className="text-xl font-bold text-white hover:text-blue-400 transition-colors ml-2"
> >
Taskboard Taskboard
</Link> </Link>
<div className="ml-10 flex items-baseline space-x-4"> <div className="hidden md:flex items-baseline ml-10 space-x-4">
<Link <Link
to="/home" to="/home"
className="text-gray-300 hover:text-white px-3 py-2 rounded-md text-sm font-medium transition-colors" className="text-gray-300 hover:text-white px-3 py-2 rounded-md text-sm font-medium transition-colors"
@ -39,7 +43,7 @@ export function Navbar() {
)} )}
</div> </div>
</div> </div>
<div className="flex items-center gap-3"> <div className="hidden md:flex items-center gap-3">
{user ? ( {user ? (
<> <>
<span className="text-gray-300 px-3 py-2">{user.username}</span> <span className="text-gray-300 px-3 py-2">{user.username}</span>
@ -67,8 +71,76 @@ export function Navbar() {
</> </>
)} )}
</div> </div>
<div className="md:hidden flex items-center">
<button
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
className="text-gray-300 hover:text-white p-2 rounded-md"
aria-label="Toggle menu"
>
{mobileMenuOpen ? <CloseIcon /> : <MenuIcon />}
</button>
</div>
</div> </div>
</div> </div>
{/* Mobile menu */}
{mobileMenuOpen && (
<div className="md:hidden bg-gray-800 border-t border-gray-700">
<div className="px-2 pt-2 pb-3 space-y-1 sm:px-3">
<Link
to="/home"
className="text-gray-300 hover:text-white block px-3 py-2 rounded-md text-base font-medium"
onClick={() => setMobileMenuOpen(false)}
>
Home
</Link>
{user && (
<Link
to="/boards"
className="text-gray-300 hover:text-white block px-3 py-2 rounded-md text-base font-medium"
onClick={() => setMobileMenuOpen(false)}
>
Boards
</Link>
)}
<div className="border-t border-gray-700 my-2"></div>
{user ? (
<>
<div className="px-3 py-2 text-gray-300 text-base font-medium">{user.username}</div>
<button
onClick={() => {
logout();
setMobileMenuOpen(false);
}}
className="text-gray-300 hover:text-white block w-full text-left px-3 py-2 rounded-md text-base font-medium"
>
Logout
</button>
</>
) : (
<>
<Link
to="/login"
className="text-gray-300 hover:text-white block px-3 py-2 rounded-md text-base font-medium"
onClick={() => setMobileMenuOpen(false)}
>
Login
</Link>
<Link
to="/register"
className="bg-blue-600 hover:bg-blue-700 text-white block px-3 py-2 rounded-md text-base font-medium"
onClick={() => setMobileMenuOpen(false)}
>
Register
</Link>
</>
)}
</div>
</div>
)}
</nav> </nav>
); );
} }

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 <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width="20" width="20"
@ -9,6 +13,7 @@ const Edit2Icon = () => (
strokeWidth="2" strokeWidth="2"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="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> <path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"></path>
</svg> </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 <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width="16" width="16"
@ -9,6 +13,7 @@ const PlusIcon = () => (
strokeWidth="2" strokeWidth="2"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
className={className}
> >
<line x1="12" y1="5" x2="12" y2="19"></line> <line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></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 <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width="16" width="16"
@ -9,6 +13,7 @@ const Trash2Icon = () => (
strokeWidth="2" strokeWidth="2"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
className={className}
> >
<polyline points="3 6 5 6 21 6"></polyline> <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> <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 labels = (card as any).labels || [];
const hasLabels = labels.length > 0; const hasLabels = labels.length > 0;
// Get epic
const epic = (card as any).epic;
const hasEpic = epic !== null && epic !== undefined;
return ( return (
<div <div
ref={setNodeRef} 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" 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 */} {/* Labels */}
{hasLabels && ( {hasLabels && (
<div className="flex flex-wrap gap-1 mb-2"> <div className="flex flex-wrap gap-1 mb-2">

View file

@ -68,8 +68,8 @@ export function KanbanColumn({
}; };
return ( 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="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"> <div className="mb-4 px-4">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2 flex-1"> <div className="flex items-center gap-2 flex-1">
{/* Drag Handle Icon */} {/* Drag Handle Icon */}
@ -133,7 +133,7 @@ export function KanbanColumn({
> >
<div <div
ref={setNodeRef} 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) => ( {cards.map((card) => (
<KanbanCard key={card.id} card={card} onOpenModal={() => onOpenCardModal(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 axios from 'axios';
import { RegisterData, UserData, ProductData, OrderData, AuthResponse } from '../types'; import { RegisterData, UserData, AuthResponse } from '../types';
import { import {
Board, Board,
BoardWithDetails, BoardWithDetails,
@ -69,44 +69,6 @@ export function useApi() {
return response.data; 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 // Boards
getBoards: async (): Promise<Board[]> => { getBoards: async (): Promise<Board[]> => {
const response = await api.get<Board[]>('/boards'); const response = await api.get<Board[]>('/boards');
@ -267,6 +229,87 @@ export function useApi() {
deleteComment: async (id: number): Promise<void> => { deleteComment: async (id: number): Promise<void> => {
await api.delete(`/comments/${id}`); 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) { } catch (err: any) {
const errorMessage = const errorMessage =
err.response?.data.error || err.message || 'Login failed. Please try again.'; err.response?.data.error || err.message || 'Login failed. Please try again.';
// debugger
// Show error toast // Show error toast
addNotification({ addNotification({
type: 'error', type: 'error',
@ -76,7 +75,6 @@ export function useAuth() {
}; };
// Store in localStorage first // Store in localStorage first
// debugger
localStorage.setItem('token', response.access_token); localStorage.setItem('token', response.access_token);
localStorage.setItem('user', JSON.stringify(user)); 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