Compare commits

...

2 commits

31 changed files with 340 additions and 2304 deletions

View file

@ -1,338 +0,0 @@
# 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

@ -1,238 +0,0 @@
# 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

@ -88,7 +88,7 @@ class TestingConfig(Config):
# Conservative connection pool settings for testing
SQLALCHEMY_ENGINE_OPTIONS = {
"pool_size": 4, # Only one connection in the pool
"pool_size": 1, # Only one connection in the pool
"max_overflow": 0, # No overflow connections allowed
"pool_timeout": 30,
"pool_recycle": 3600, # Recycle after 1 hour

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

@ -3,8 +3,7 @@ from functools import wraps
from flask import abort, g
from flask_jwt_extended import get_jwt_identity
from app.models import (Board, Card, CheckItem, Checklist, Comment,
FileAttachment, List)
from app.models import Board, Card, Comment, FileAttachment
def get_current_user_id():
@ -15,176 +14,6 @@ def get_current_user_id():
return g.jwt_identity
def load_board_owned(f):
"""
Loads a Board and ensures it belongs to the current user.
Injects 'board' into the route kwargs.
Aborts with 404 if not found or not owned.
"""
@wraps(f)
def decorated_function(*args, **kwargs):
user_id = get_current_user_id()
board_id = kwargs.get("board_id")
# SECURE QUERY: Filter by ID *and* User ID in the DB
board = Board.query.filter_by(id=board_id, user_id=user_id).first()
if not board:
abort(404)
kwargs["board"] = board
return f(*args, **kwargs)
return decorated_function
def load_card_owned(f):
"""
Loads a Card and ensures its Parent Board belongs to the current user.
Injects 'card' into the route kwargs.
"""
@wraps(f)
def decorated_function(*args, **kwargs):
user_id = get_current_user_id()
card_id = kwargs.get("card_id")
# Join Board to check ownership securely in one query
card = (
Card.query.join(Board)
.filter(Card.id == card_id, Board.user_id == user_id)
.first()
)
if not card:
abort(404)
kwargs["card"] = card
return f(*args, **kwargs)
return decorated_function
def load_list_owned(f):
"""Loads a List ensuring Parent Board ownership."""
@wraps(f)
def decorated_function(*args, **kwargs):
user_id = get_current_user_id()
list_id = kwargs.get("list_id")
lst = (
List.query.join(Board)
.filter(List.id == list_id, Board.user_id == user_id)
.first()
)
if not lst:
abort(404)
kwargs["lst"] = lst
return f(*args, **kwargs)
return decorated_function
def load_checklist_owned(f):
"""Loads a Checklist ensuring Parent Board ownership."""
@wraps(f)
def decorated_function(*args, **kwargs):
user_id = get_current_user_id()
checklist_id = kwargs.get("checklist_id")
checklist = (
Checklist.query.join(Card)
.join(Board)
.filter(Checklist.id == checklist_id, Board.user_id == user_id)
.first()
)
if not checklist:
abort(404)
kwargs["checklist"] = checklist
return f(*args, **kwargs)
return decorated_function
def load_check_item_owned(f):
"""Loads a CheckItem ensuring Parent Board ownership."""
@wraps(f)
def decorated_function(*args, **kwargs):
user_id = get_current_user_id()
item_id = kwargs.get("item_id")
check_item = (
CheckItem.query.join(Checklist)
.join(Card)
.join(Board)
.filter(CheckItem.id == item_id, Board.user_id == user_id)
.first()
)
if not check_item:
abort(404)
kwargs["check_item"] = check_item
return f(*args, **kwargs)
return decorated_function
def load_comment_owned(f):
"""
Loads a Comment ensuring the Comment itself belongs to the user.
(Based on schema where Comment has user_id)
"""
@wraps(f)
def decorated_function(*args, **kwargs):
user_id = get_current_user_id()
comment_id = kwargs.get("comment_id")
comment = Comment.query.filter_by(id=comment_id, user_id=user_id).first()
if not comment:
abort(404)
kwargs["comment"] = comment
return f(*args, **kwargs)
return decorated_function
def load_file_owned(f):
"""
Loads a FileAttachment ensuring it belongs to the user.
Aborts with 404 if not found or not owned.
"""
@wraps(f)
def decorated_function(*args, **kwargs):
user_id = get_current_user_id()
file_id = kwargs.get("file_id")
# Filter by ID and user ID
attachment = FileAttachment.query.filter_by(
id=file_id, uploaded_by=user_id
).first()
if not attachment:
abort(404)
kwargs["file"] = attachment
return f(*args, **kwargs)
return decorated_function
def load_file_accessible(f):
"""
Loads a FileAttachment ensuring the user can access it.

View file

@ -0,0 +1,177 @@
from functools import wraps
from flask import abort
from app.decorators.decorators import get_current_user_id
from app.models import (Board, Card, CheckItem, Checklist, Comment,
FileAttachment, List)
def load_board_owned(f):
"""
Loads a Board and ensures it belongs to the current user.
Injects 'board' into the route kwargs.
Aborts with 404 if not found or not owned.
"""
@wraps(f)
def decorated_function(*args, **kwargs):
user_id = get_current_user_id()
board_id = kwargs.get("board_id")
# SECURE QUERY: Filter by ID *and* User ID in the DB
board = Board.query.filter_by(id=board_id, user_id=user_id).first()
if not board:
abort(404)
kwargs["board"] = board
return f(*args, **kwargs)
return decorated_function
def load_card_owned(f):
"""
Loads a Card and ensures its Parent Board belongs to the current user.
Injects 'card' into the route kwargs.
"""
@wraps(f)
def decorated_function(*args, **kwargs):
user_id = get_current_user_id()
card_id = kwargs.get("card_id")
# Join Board to check ownership securely in one query
card = (
Card.query.join(Board)
.filter(Card.id == card_id, Board.user_id == user_id)
.first()
)
if not card:
abort(404)
kwargs["card"] = card
return f(*args, **kwargs)
return decorated_function
def load_list_owned(f):
"""Loads a List ensuring Parent Board ownership."""
@wraps(f)
def decorated_function(*args, **kwargs):
user_id = get_current_user_id()
list_id = kwargs.get("list_id")
lst = (
List.query.join(Board)
.filter(List.id == list_id, Board.user_id == user_id)
.first()
)
if not lst:
abort(404)
kwargs["lst"] = lst
return f(*args, **kwargs)
return decorated_function
def load_checklist_owned(f):
"""Loads a Checklist ensuring Parent Board ownership."""
@wraps(f)
def decorated_function(*args, **kwargs):
user_id = get_current_user_id()
checklist_id = kwargs.get("checklist_id")
checklist = (
Checklist.query.join(Card)
.join(Board)
.filter(Checklist.id == checklist_id, Board.user_id == user_id)
.first()
)
if not checklist:
abort(404)
kwargs["checklist"] = checklist
return f(*args, **kwargs)
return decorated_function
def load_check_item_owned(f):
"""Loads a CheckItem ensuring Parent Board ownership."""
@wraps(f)
def decorated_function(*args, **kwargs):
user_id = get_current_user_id()
item_id = kwargs.get("item_id")
check_item = (
CheckItem.query.join(Checklist)
.join(Card)
.join(Board)
.filter(CheckItem.id == item_id, Board.user_id == user_id)
.first()
)
if not check_item:
abort(404)
kwargs["check_item"] = check_item
return f(*args, **kwargs)
return decorated_function
def load_comment_owned(f):
"""
Loads a Comment ensuring the Comment itself belongs to the user.
(Based on schema where Comment has user_id)
"""
@wraps(f)
def decorated_function(*args, **kwargs):
user_id = get_current_user_id()
comment_id = kwargs.get("comment_id")
comment = Comment.query.filter_by(id=comment_id, user_id=user_id).first()
if not comment:
abort(404)
kwargs["comment"] = comment
return f(*args, **kwargs)
return decorated_function
def load_file_owned(f):
"""
Loads a FileAttachment ensuring it belongs to the user.
Aborts with 404 if not found or not owned.
"""
@wraps(f)
def decorated_function(*args, **kwargs):
user_id = get_current_user_id()
file_id = kwargs.get("file_id")
# Filter by ID and user ID
attachment = FileAttachment.query.filter_by(
id=file_id, uploaded_by=user_id
).first()
if not attachment:
abort(404)
kwargs["file"] = attachment
return f(*args, **kwargs)
return decorated_function

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

@ -8,16 +8,11 @@ from app.models.epic import Epic
from app.models.file_attachment import FileAttachment
from app.models.label import Label
from app.models.list_model import List
from app.models.order import Order, OrderItem
from app.models.product import Product
from app.models.user import User
from app.models.wiki import Wiki, wiki_entity_links
__all__ = [
"User",
"Product",
"Order",
"OrderItem",
"Board",
"List",
"Card",

View file

@ -1,75 +0,0 @@
from datetime import UTC, datetime
from app import db
class Order(db.Model):
"""Order model"""
__tablename__ = "orders"
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
status = db.Column(db.String(20), default="pending", index=True)
total_amount = db.Column(db.Numeric(10, 2), nullable=False)
shipping_address = db.Column(db.Text)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(UTC))
updated_at = db.Column(
db.DateTime,
default=lambda: datetime.now(UTC),
onupdate=lambda: datetime.now(UTC),
)
# Relationships
user = db.relationship("User", back_populates="orders")
items = db.relationship(
"OrderItem",
back_populates="order",
lazy="dynamic",
cascade="all, delete-orphan",
)
def to_dict(self):
"""Convert order to dictionary"""
return {
"id": self.id,
"user_id": self.user_id,
"status": self.status,
"total_amount": float(self.total_amount) if self.total_amount else None,
"shipping_address": self.shipping_address,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
"items": [item.to_dict() for item in self.items],
}
def __repr__(self):
return f"<Order {self.id}>"
class OrderItem(db.Model):
"""Order Item model"""
__tablename__ = "order_items"
id = db.Column(db.Integer, primary_key=True)
order_id = db.Column(db.Integer, db.ForeignKey("orders.id"), nullable=False)
product_id = db.Column(db.Integer, db.ForeignKey("products.id"), nullable=False)
quantity = db.Column(db.Integer, nullable=False)
price = db.Column(db.Numeric(10, 2), nullable=False)
# Relationships
order = db.relationship("Order", back_populates="items")
product = db.relationship("Product", back_populates="order_items")
def to_dict(self):
"""Convert order item to dictionary"""
return {
"id": self.id,
"order_id": self.order_id,
"product_id": self.product_id,
"quantity": self.quantity,
"price": float(self.price) if self.price else None,
}
def __repr__(self):
return f"<OrderItem {self.id}>"

View file

@ -1,43 +0,0 @@
from datetime import UTC, datetime
from app import db
class Product(db.Model):
"""Product model"""
__tablename__ = "products"
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(200), nullable=False, index=True)
description = db.Column(db.Text)
price = db.Column(db.Numeric(10, 2), nullable=False)
stock = db.Column(db.Integer, default=0)
image_url = db.Column(db.String(500))
is_active = db.Column(db.Boolean, default=True)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(UTC))
updated_at = db.Column(
db.DateTime,
default=lambda: datetime.now(UTC),
onupdate=lambda: datetime.now(UTC),
)
# Relationships
order_items = db.relationship("OrderItem", back_populates="product", lazy="dynamic")
def to_dict(self):
"""Convert product to dictionary"""
return {
"id": self.id,
"name": self.name,
"description": self.description,
"price": float(self.price) if self.price else None,
"stock": self.stock,
"image_url": self.image_url,
"is_active": self.is_active,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
def __repr__(self):
return f"<Product {self.name}>"

View file

@ -26,7 +26,6 @@ class User(db.Model):
)
# Relationships
orders = db.relationship("Order", back_populates="user", lazy="dynamic")
boards = db.relationship(
"Board", backref="user", cascade="all, delete-orphan", lazy="dynamic"
)

View file

@ -76,336 +76,3 @@ def get_current_user():
return jsonify({"error": "User not found"}), 404
return jsonify(user.to_dict()), 200
# # Product Routes
# @api_bp.route("/products", methods=["GET"])
# def get_products():
# """Get all products"""
# # time.sleep(5) # This adds a 5 second delay
# products = Product.query.filter_by(is_active=True).all()
# return jsonify([product.to_dict() for product in products]), 200
# @api_bp.route("/products/<int:product_id>", methods=["GET"])
# def get_product(product_id):
# """Get a single product"""
# product = db.session.get(Product, product_id)
# if not product:
# return jsonify({"error": "Product not found"}), 404
# return jsonify(product.to_dict()), 200
# @api_bp.route("/products", methods=["POST"])
# @jwt_required()
# def create_product():
# """Create a new product (admin only)"""
# user_id = int(get_jwt_identity())
# user = db.session.get(User, user_id)
# if not user or not user.is_admin:
# return jsonify({"error": "Admin access required"}), 403
# try:
# # Validate request data using Pydantic schema
# product_data = ProductCreateRequest(**request.get_json())
# product = Product(
# name=product_data.name,
# description=product_data.description,
# price=product_data.price,
# stock=product_data.stock,
# image_url=product_data.image_url,
# )
# db.session.add(product)
# db.session.commit()
# # Use Pydantic schema for response
# response = ProductResponse.model_validate(product)
# return jsonify(response.model_dump()), 201
# except ValidationError as e:
# print(f"Pydantic Validation Error: {e.errors()}")
# return jsonify({"error": "Validation error", "details": e.errors()}), 400
# @api_bp.route("/products/<int:product_id>", methods=["PUT"])
# @jwt_required()
# def update_product(product_id):
# """Update a product (admin only)"""
# user_id = int(get_jwt_identity())
# user = db.session.get(User, user_id)
# if not user or not user.is_admin:
# return jsonify({"error": "Admin access required"}), 403
# product = db.session.get(Product, product_id)
# if not product:
# return jsonify({"error": "Product not found"}), 404
# data = request.get_json()
# product.name = data.get("name", product.name)
# product.description = data.get("description", product.description)
# product.price = data.get("price", product.price)
# product.stock = data.get("stock", product.stock)
# product.image_url = data.get("image_url", product.image_url)
# product.is_active = data.get("is_active", product.is_active)
# db.session.commit()
# return jsonify(product.to_dict()), 200
# @api_bp.route("/products/<int:product_id>", methods=["DELETE"])
# @jwt_required()
# def delete_product(product_id):
# """Delete a product (admin only)"""
# user_id = int(get_jwt_identity())
# user = db.session.get(User, user_id)
# if not user or not user.is_admin:
# return jsonify({"error": "Admin access required"}), 403
# product = db.session.get(Product, product_id)
# if not product:
# return jsonify({"error": "Product not found"}), 404
# db.session.delete(product)
# db.session.commit()
# return jsonify({"message": "Product deleted"}), 200
# # Order Routes
# @api_bp.route("/orders", methods=["GET"])
# @jwt_required()
# def get_orders():
# """Get all orders for current user"""
# user_id = int(get_jwt_identity())
# orders = Order.query.filter_by(user_id=user_id).all()
# return jsonify([order.to_dict() for order in orders]), 200
# @api_bp.route("/orders", methods=["POST"])
# @jwt_required()
# def create_order():
# """Create a new order"""
# user_id = int(get_jwt_identity())
# data = request.get_json()
# if not data or not data.get("items"):
# return jsonify({"error": "Order items are required"}), 400
# total_amount = 0
# order_items = []
# for item_data in data["items"]:
# product = db.session.get(Product, item_data["product_id"])
# if not product:
# return (
# jsonify({"error": f'Product {item_data["product_id"]} not found'}),
# 404,
# )
# if product.stock < item_data["quantity"]:
# return jsonify({"error": f"Insufficient stock for {product.name}"}), 400
# item_total = product.price * item_data["quantity"]
# total_amount += item_total
# order_items.append(
# {
# "product": product,
# "quantity": item_data["quantity"],
# "price": product.price,
# }
# )
# order = Order(
# user_id=user_id,
# total_amount=total_amount,
# shipping_address=data.get("shipping_address"),
# )
# db.session.add(order)
# db.session.flush()
# for item_data in order_items:
# order_item = OrderItem(
# order_id=order.id,
# product_id=item_data["product"].id,
# quantity=item_data["quantity"],
# price=item_data["price"],
# )
# item_data["product"].stock -= item_data["quantity"]
# db.session.add(order_item)
# db.session.commit()
# return jsonify(order.to_dict()), 201
# @api_bp.route("/orders/<int:order_id>", methods=["GET"])
# @jwt_required()
# def get_order(order_id):
# """Get a single order"""
# user_id = int(get_jwt_identity())
# order = db.session.get(Order, order_id)
# if not order:
# return jsonify({"error": "Order not found"}), 404
# if order.user_id != user_id:
# user = db.session.get(User, user_id)
# if not user or not user.is_admin:
# return jsonify({"error": "Access denied"}), 403
# return jsonify(order.to_dict()), 200
# # Celery Task Routes
# @api_bp.route("/tasks/hello", methods=["POST"])
# @jwt_required()
# def trigger_hello_task():
# """Trigger the hello task"""
# data = request.get_json() or {}
# name = data.get("name", "World")
# task = celery.send_task("tasks.print_hello", args=[name])
# return (
# jsonify(
# {"message": "Hello task triggered", "task_id": task.id, "status":
# "pending"}
# ),
# 202,
# )
# @api_bp.route("/tasks/divide", methods=["POST"])
# @jwt_required()
# def trigger_divide_task():
# """Trigger the divide numbers task"""
# data = request.get_json() or {}
# x = data.get("x", 10)
# y = data.get("y", 2)
# task = celery.send_task("tasks.divide_numbers", args=[x, y])
# return (
# jsonify(
# {
# "message": "Divide task triggered",
# "task_id": task.id,
# "operation": f"{x} / {y}",
# "status": "pending",
# }
# ),
# 202,
# )
# @api_bp.route("/tasks/report", methods=["POST"])
# @jwt_required()
# def trigger_report_task():
# """Trigger the daily report task"""
# task = celery.send_task("tasks.send_daily_report")
# return (
# jsonify(
# {
# "message": "Daily report task triggered",
# "task_id": task.id,
# "status": "pending",
# }
# ),
# 202,
# )
# @api_bp.route("/tasks/stats", methods=["POST"])
# @jwt_required()
# def trigger_stats_task():
# """Trigger product statistics update task"""
# data = request.get_json() or {}
# product_id = data.get("product_id")
# if product_id:
# task = celery.send_task("tasks.update_product_statistics", args=[product_id])
# message = f"Product statistics update triggered for product {product_id}"
# else:
# task = celery.send_task("tasks.update_product_statistics", args=[None])
# message = "Product statistics update triggered for all products"
# return jsonify({"message": message, "task_id": task.id, "status": "pending"}), 202
# @api_bp.route("/tasks/long-running", methods=["POST"])
# @jwt_required()
# def trigger_long_running_task():
# """Trigger a long-running task"""
# data = request.get_json() or {}
# iterations = data.get("iterations", 10)
# task = celery.send_task("tasks.long_running_task", args=[iterations])
# return (
# jsonify(
# {
# "message": f"Long-running task triggered with {iterations}
# iterations",
# "task_id": task.id,
# "status": "pending",
# }
# ),
# 202,
# )
# @api_bp.route("/tasks/<task_id>", methods=["GET"])
# @jwt_required()
# def get_task_status(task_id):
# """Get the status of a Celery task"""
# task_result = celery.AsyncResult(task_id)
# response = {
# "task_id": task_id,
# "status": task_result.status,
# "ready": task_result.ready(),
# }
# if task_result.ready():
# if task_result.successful():
# response["result"] = task_result.result
# else:
# response["error"] = str(task_result.result)
# response["traceback"] = task_result.traceback
# return jsonify(response), 200
# @api_bp.route("/tasks/health", methods=["GET"])
# def celery_health():
# """Check Celery health"""
# try:
# # Try to ping the worker
# inspector = celery.control.inspect()
# stats = inspector.stats()
# if stats:
# return (
# jsonify(
# {"status": "healthy", "workers": len(stats), "workers_info":
# stats}
# ),
# 200,
# )
# else:
# return (
# jsonify({"status": "unhealthy", "message": "No workers available"}),
# 503,
# )
# except Exception as e:
# return jsonify({"status": "error", "message": str(e)}), 500

View file

@ -5,7 +5,8 @@ from flask_jwt_extended import jwt_required
from flask_pydantic import validate
from app import db
from app.decorators import get_current_user_id, load_board_owned
from app.decorators import load_board_owned
from app.decorators.decorators import get_current_user_id
from app.models import Board, Card, CardLabel, Label, List
from app.schemas import (BoardCreateRequest, BoardResponse,
BoardWithDetailsResponse)

View file

@ -5,14 +5,17 @@ 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 EpicCreateRequest, EpicResponse, EpicUpdateRequest
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)
@ -20,12 +23,13 @@ def get_board_epics(board_id):
return {"error": "Board not found"}, 404
epics = Epic.query.filter_by(board_id=board_id).all()
return [EpicResponse.model_validate(epic).model_dump() for epic in epics], 200
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)
@ -49,7 +53,7 @@ def create_epic(board_id, body: EpicCreateRequest):
db.session.add(epic)
db.session.commit()
return EpicResponse.model_validate(epic).model_dump(), 201
return epic, 201
@kanban_bp.route("/epics/<int:epic_id>", methods=["GET"])
@ -93,6 +97,7 @@ def _update_epic_fields(epic: Epic, body: EpicUpdateRequest) -> None:
@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)
@ -102,7 +107,7 @@ def update_epic(epic_id, body: EpicUpdateRequest):
_update_epic_fields(epic, body)
db.session.commit()
return EpicResponse.model_validate(epic).model_dump(), 200
return epic, 200
@kanban_bp.route("/epics/<int:epic_id>", methods=["DELETE"])
@ -125,6 +130,7 @@ def delete_epic(epic_id):
@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)
@ -144,7 +150,7 @@ def add_epic_to_card(card_id):
card.epic_id = epic_id
db.session.commit()
return card.to_dict(), 200
return card, 200
@kanban_bp.route("/cards/<int:card_id>/epics/<int:epic_id>", methods=["DELETE"])

View file

@ -9,6 +9,7 @@ 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
@ -29,6 +30,7 @@ def generate_slug(name):
@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)
@ -36,12 +38,13 @@ def get_board_wikis(board_id):
return {"error": "Board not found"}, 404
wikis = Wiki.query.filter_by(board_id=board_id).all()
return [WikiResponse.model_validate(wiki).model_dump() for wiki in wikis], 200
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)
@ -75,7 +78,7 @@ def create_wiki(board_id, body: WikiCreateRequest):
db.session.add(wiki)
db.session.commit()
return WikiResponse.model_validate(wiki).model_dump(), 201
return wiki, 201
@kanban_bp.route("/wikis/<int:wiki_id>", methods=["GET"])
@ -108,6 +111,7 @@ def get_wiki(wiki_id):
@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)
@ -131,7 +135,7 @@ def update_wiki(wiki_id, body: WikiUpdateRequest):
wiki.updated_by = get_jwt_identity()
db.session.commit()
return WikiResponse.model_validate(wiki).model_dump(), 200
return wiki, 200
@kanban_bp.route("/wikis/<int:wiki_id>", methods=["DELETE"])

View file

@ -15,14 +15,11 @@ from app.schemas.file_attachment import (FileAttachmentWithUrlResponse,
ValidationError)
from app.schemas.label import LabelCreateRequest, LabelResponse
from app.schemas.list_model import ListCreateRequest, ListResponse
from app.schemas.product import ProductCreateRequest, ProductResponse
from app.schemas.wiki import (WikiCreateRequest, WikiEntityLink,
WikiLinkRequest, WikiResponse, WikiUpdateRequest)
__all__ = [
# Product schemas
"ProductCreateRequest",
"ProductResponse",
# Board schemas
"BoardCreateRequest",
"BoardResponse",

View file

@ -47,6 +47,7 @@ class CardResponse(BaseModel):
"pos": 65535.0,
"list_id": 1,
"board_id": 1,
"epic_id": 1,
"due": "2024-12-31T23:59:59",
"due_complete": False,
"badges": {"votes": 0},
@ -64,6 +65,7 @@ class CardResponse(BaseModel):
pos: float
list_id: int
board_id: int
epic_id: Optional[int]
due: Optional[datetime] = None
due_complete: bool
badges: Optional[Dict[str, Any]] = None

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

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

@ -1,5 +1,6 @@
"""Pytest configuration and fixtures"""
import logging
import time
from io import BytesIO
import pytest
@ -7,8 +8,7 @@ from faker import Faker
from PIL import Image
from app import create_app, db
from app.models import (Board, Card, Comment, List, Order, OrderItem, Product,
User)
from app.models import Board, Card, Comment, List, User
log = logging.getLogger(__name__)
fake = Faker()
@ -116,7 +116,7 @@ def regular_user(db_session):
"""Create a regular user for testing"""
user = User(
email=fake.email(),
username=fake.user_name(),
username=f"{fake.user_name()}_{int(time.time() * 1000)}",
first_name=fake.first_name(),
last_name=fake.last_name(),
is_admin=False,
@ -145,39 +145,6 @@ def inactive_user(db_session):
return user
@pytest.fixture
def product(db_session):
"""Create a product for testing"""
product = Product(
name=fake.sentence(nb_words=4)[:-1], # Remove period
description=fake.paragraph(),
price=fake.pydecimal(left_digits=2, right_digits=2, positive=True),
stock=fake.pyint(min_value=0, max_value=100),
image_url=fake.url(),
)
db_session.add(product)
db_session.commit()
return product
@pytest.fixture
def products(db_session):
"""Create multiple products for testing"""
products = []
for _ in range(5):
product = Product(
name=fake.sentence(nb_words=4)[:-1],
description=fake.paragraph(),
price=fake.pydecimal(left_digits=2, right_digits=2, positive=True),
stock=fake.pyint(min_value=20, max_value=100),
image_url=fake.url(),
)
db_session.add(product)
products.append(product)
db_session.commit()
return products
@pytest.fixture
def auth_headers(client, regular_user):
"""Get authentication headers for a regular user"""
@ -203,33 +170,6 @@ def admin_headers(client, admin_user):
return {"Authorization": f"Bearer {token}"}
@pytest.fixture
def order(db_session, regular_user, products):
print("-----order-created------")
"""Create an order for testing"""
order = Order(
user_id=regular_user.id, total_amount=0.0, shipping_address=fake.address()
)
db_session.add(order)
db_session.flush()
total_amount = 0
for i, product in enumerate(products[:2]):
quantity = fake.pyint(min_value=1, max_value=5)
order_item = OrderItem(
order_id=order.id,
product_id=product.id,
quantity=quantity,
price=product.price,
)
total_amount += float(product.price) * quantity
db_session.add(order_item)
order.total_amount = total_amount
db_session.commit()
return order
# ============ File Upload Fixtures ============

View file

@ -1,9 +1,8 @@
"""Test models"""
from decimal import Decimal
import pytest
from app.models import Order, OrderItem, Product, User
from app.models import User
class TestUserModel:
@ -69,132 +68,3 @@ class TestUserModel:
db_session.commit()
assert repr(user) == "<User testuser3>"
class TestProductModel:
"""Test Product model"""
@pytest.mark.unit
def test_product_creation(self, db_session):
"""Test creating a product"""
product = Product(
name="Test Product",
description="A test product",
price=Decimal("99.99"),
stock=10,
image_url="https://example.com/product.jpg",
)
db_session.add(product)
db_session.commit()
assert product.id is not None
assert product.name == "Test Product"
assert product.price == Decimal("99.99")
assert product.stock == 10
assert product.is_active is True
@pytest.mark.unit
def test_product_to_dict(self, db_session):
"""Test product serialization to dictionary"""
product = Product(
name="Test Product",
description="A test product",
price=Decimal("99.99"),
stock=10,
)
db_session.add(product)
db_session.commit()
product_dict = product.to_dict()
assert product_dict["name"] == "Test Product"
assert product_dict["price"] == 99.99
assert isinstance(product_dict["created_at"], str)
assert isinstance(product_dict["updated_at"], str)
@pytest.mark.unit
def test_product_defaults(self, db_session):
"""Test product default values"""
product = Product(name="Test Product", price=Decimal("9.99"))
db_session.add(product)
db_session.commit()
assert product.stock == 0
assert product.is_active is True
assert product.description is None
assert product.image_url is None
@pytest.mark.unit
def test_product_repr(self, db_session):
"""Test product string representation"""
product = Product(name="Test Product", price=Decimal("9.99"))
db_session.add(product)
db_session.commit()
assert repr(product) == "<Product Test Product>"
class TestOrderModel:
"""Test Order model"""
@pytest.mark.unit
def test_order_creation(self, db_session, regular_user):
"""Test creating an order"""
order = Order(
user_id=regular_user.id,
total_amount=Decimal("199.99"),
shipping_address="123 Test St",
)
db_session.add(order)
db_session.commit()
assert order.id is not None
assert order.user_id == regular_user.id
assert order.total_amount == Decimal("199.99")
@pytest.mark.unit
def test_order_to_dict(self, db_session, regular_user):
"""Test order serialization to dictionary"""
order = Order(
user_id=regular_user.id,
total_amount=Decimal("199.99"),
shipping_address="123 Test St",
)
db_session.add(order)
db_session.commit()
order_dict = order.to_dict()
assert order_dict["user_id"] == regular_user.id
assert order_dict["total_amount"] == 199.99
assert isinstance(order_dict["created_at"], str)
class TestOrderItemModel:
"""Test OrderItem model"""
@pytest.mark.unit
def test_order_item_creation(self, db_session, order, product):
"""Test creating an order item"""
order_item = OrderItem(
order_id=order.id, product_id=product.id, quantity=2, price=product.price
)
db_session.add(order_item)
db_session.commit()
assert order_item.id is not None
assert order_item.order_id == order.id
assert order_item.product_id == product.id
assert order_item.quantity == 2
@pytest.mark.unit
def test_order_item_to_dict(self, db_session, order, product):
"""Test order item serialization to dictionary"""
order_item = OrderItem(
order_id=order.id, product_id=product.id, quantity=2, price=product.price
)
db_session.add(order_item)
db_session.commit()
item_dict = order_item.to_dict()
assert item_dict["order_id"] == order.id
assert item_dict["product_id"] == product.id
assert item_dict["quantity"] == 2

View file

@ -112,235 +112,3 @@ class TestAuthRoutes:
"""Test getting current user without authentication"""
response = client.get("/api/users/me")
assert response.status_code == 401
# class TestProductRoutes:
# """Test product routes"""
# @pytest.mark.product
# def test_get_products(self, app, client, products):
# """Test getting all products"""
# from app.models import Product
# before_count = Product.query.count()
# response = client.get("/api/products")
# assert response.status_code == 200
# data = response.get_json()
# assert len(data) == before_count
# @pytest.mark.product
# def test_get_products_empty(self, client):
# """Test getting products when none exist"""
# from app.models import Product
# before_count = Product.query.count()
# response = client.get("/api/products")
# assert response.status_code == 200
# data = response.get_json()
# assert len(data) == before_count
# @pytest.mark.product
# def test_get_single_product(self, client, product):
# """Test getting a single product"""
# response = client.get(f"/api/products/{product.id}")
# assert response.status_code == 200
# data = response.get_json()
# assert data["id"] == product.id
# assert data["name"] == product.name
# @pytest.mark.product
# def test_get_product_not_found(self, client):
# """Test getting non-existent product"""
# response = client.get("/api/products/999")
# assert response.status_code == 404
# @pytest.mark.product
# def test_create_product_admin(self, client, admin_headers):
# """Test creating product as admin"""
# response = client.post(
# "/api/products",
# headers=admin_headers,
# json={
# "name": "New Product",
# "description": "A new product",
# "price": 29.99,
# "stock": 10,
# },
# )
# assert response.status_code == 201
# data = response.get_json()
# assert data["name"] == "New Product"
# assert data["price"] == 29.99
# @pytest.mark.product
# def test_create_product_regular_user(self, client, auth_headers):
# """Test creating product as regular user (should fail)"""
# response = client.post(
# "/api/products",
# headers=auth_headers,
# json={"name": "New Product", "price": 29.99},
# )
# assert response.status_code == 403
# data = response.get_json()
# assert "admin" in data["error"].lower()
# @pytest.mark.product
# def test_create_product_unauthorized(self, client):
# """Test creating product without authentication"""
# response = client.post(
# "/api/products", json={"name": "New Product", "price": 29.99}
# )
# assert response.status_code == 401
# @pytest.mark.product
# def test_create_product_validation_error(self, client, admin_headers):
# """Test creating product with invalid data"""
# response = client.post(
# "/api/products",
# headers=admin_headers,
# json={"name": "New Product", "price": -10.99},
# )
# assert response.status_code == 400
# data = response.get_json()
# assert "Validation error" in data["error"]
# @pytest.mark.product
# def test_create_product_missing_required_fields(self, client, admin_headers):
# """Test creating product with missing required fields"""
# response = client.post(
# "/api/products",
# headers=admin_headers,
# json={"description": "Missing name and price"},
# )
# assert response.status_code == 400
# data = response.get_json()
# assert "Validation error" in data["error"]
# @pytest.mark.product
# def test_create_product_minimal_data(self, client, admin_headers):
# """Test creating product with minimal valid data"""
# response = client.post(
# "/api/products",
# headers=admin_headers,
# json={"name": "Minimal Product", "price": 19.99},
# )
# assert response.status_code == 201
# data = response.get_json()
# assert data["name"] == "Minimal Product"
# assert data["stock"] == 0 # Default value
# @pytest.mark.product
# def test_update_product_admin(self, client, admin_headers, product):
# """Test updating product as admin"""
# response = client.put(
# f"/api/products/{product.id}",
# headers=admin_headers,
# json={"name": "Updated Product", "price": 39.99},
# )
# assert response.status_code == 200
# data = response.get_json()
# assert data["name"] == "Updated Product"
# assert data["price"] == 39.99
# @pytest.mark.product
# def test_delete_product_admin(self, client, admin_headers, product):
# """Test deleting product as admin"""
# response = client.delete(f"/api/products/{product.id}", headers=admin_headers)
# assert response.status_code == 200
# # Verify product is deleted
# response = client.get(f"/api/products/{product.id}")
# assert response.status_code == 404
# class TestOrderRoutes:
# """Test order routes"""
# @pytest.mark.order
# def test_get_orders(self, client, auth_headers, order):
# """Test getting orders for current user"""
# response = client.get("/api/orders", headers=auth_headers)
# assert response.status_code == 200
# data = response.get_json()
# assert len(data) >= 1
# @pytest.mark.order
# def test_get_orders_unauthorized(self, client):
# """Test getting orders without authentication"""
# response = client.get("/api/orders")
# assert response.status_code == 401
# @pytest.mark.order
# def test_create_order(self, client, auth_headers, products):
# """Test creating an order"""
# response = client.post(
# "/api/orders",
# headers=auth_headers,
# json={
# "items": [
# {"product_id": products[0].id, "quantity": 2},
# {"product_id": products[1].id, "quantity": 1},
# ],
# "shipping_address": "123 Test St",
# },
# )
# assert response.status_code == 201
# data = response.get_json()
# assert "id" in data
# assert len(data["items"]) == 2
# @pytest.mark.order
# def test_create_order_insufficient_stock(
# self, client, auth_headers, db_session, products
# ):
# """Test creating order with insufficient stock"""
# # Set stock to 0
# products[0].stock = 0
# db_session.commit()
# response = client.post(
# "/api/orders",
# headers=auth_headers,
# json={"items": [{"product_id": products[0].id, "quantity": 2}]},
# )
# assert response.status_code == 400
# data = response.get_json()
# assert "insufficient" in data["error"].lower()
# @pytest.mark.order
# def test_get_single_order(self, client, auth_headers, order):
# """Test getting a single order"""
# response = client.get(f"/api/orders/{order.id}", headers=auth_headers)
# print("test_get_single_order", response.get_json())
# assert response.status_code == 200
# data = response.get_json()
# assert data["id"] == order.id
# @pytest.mark.order
# def test_get_other_users_order(self, client, admin_headers, regular_user,
# products):
# """Test admin accessing another user's order"""
# # Create an order for regular_user
# client.post(
# "/api/auth/login",
# json={"email": regular_user.email, "password": "password123"},
# )
# # Admin should be able to access any order
# # This test assumes order exists, adjust as needed
# pass

View file

@ -1,249 +0,0 @@
"""Test Pydantic schemas"""
from decimal import Decimal
import pytest
from pydantic import ValidationError
from app.schemas import ProductCreateRequest, ProductResponse
class TestProductCreateRequestSchema:
"""Test ProductCreateRequest schema"""
@pytest.mark.unit
def test_valid_product_request(self):
"""Test valid product creation request"""
data = {
"name": "Handcrafted Wooden Bowl",
"description": "A beautiful handcrafted bowl",
"price": 45.99,
"stock": 10,
"image_url": "https://example.com/bowl.jpg",
}
product = ProductCreateRequest(**data)
assert product.name == data["name"]
assert product.description == data["description"]
assert product.price == Decimal("45.99")
assert product.stock == 10
assert product.image_url == data["image_url"]
@pytest.mark.unit
def test_minimal_valid_request(self):
"""Test minimal valid request (only required fields)"""
data = {"name": "Simple Product", "price": 19.99}
product = ProductCreateRequest(**data)
assert product.name == "Simple Product"
assert product.price == Decimal("19.99")
assert product.stock == 0
assert product.description is None
assert product.image_url is None
@pytest.mark.unit
def test_missing_name(self):
"""Test request with missing name"""
data = {"price": 19.99}
with pytest.raises(ValidationError) as exc_info:
ProductCreateRequest(**data)
errors = exc_info.value.errors()
assert any(error["loc"] == ("name",) for error in errors)
@pytest.mark.unit
def test_missing_price(self):
"""Test request with missing price"""
data = {"name": "Test Product"}
with pytest.raises(ValidationError) as exc_info:
ProductCreateRequest(**data)
errors = exc_info.value.errors()
assert any(error["loc"] == ("price",) for error in errors)
@pytest.mark.unit
def test_invalid_price_negative(self):
"""Test request with negative price"""
data = {"name": "Test Product", "price": -10.99}
with pytest.raises(ValidationError) as exc_info:
ProductCreateRequest(**data)
errors = exc_info.value.errors()
assert any(error["type"] == "greater_than" for error in errors)
@pytest.mark.unit
def test_invalid_price_zero(self):
"""Test request with zero price"""
data = {"name": "Test Product", "price": 0.0}
with pytest.raises(ValidationError) as exc_info:
ProductCreateRequest(**data)
errors = exc_info.value.errors()
assert any(error["type"] == "greater_than" for error in errors)
@pytest.mark.unit
def test_invalid_price_too_many_decimals(self):
"""Test request with too many decimal places"""
data = {"name": "Test Product", "price": 10.999}
with pytest.raises(ValidationError) as exc_info:
ProductCreateRequest(**data)
errors = exc_info.value.errors()
assert any("decimal places" in str(error).lower() for error in errors)
@pytest.mark.unit
def test_invalid_stock_negative(self):
"""Test request with negative stock"""
data = {"name": "Test Product", "price": 19.99, "stock": -5}
with pytest.raises(ValidationError) as exc_info:
ProductCreateRequest(**data)
errors = exc_info.value.errors()
assert any(error["type"] == "greater_than_equal" for error in errors)
@pytest.mark.unit
def test_name_too_long(self):
"""Test request with name exceeding max length"""
data = {"name": "A" * 201, "price": 19.99} # Exceeds 200 character limit
with pytest.raises(ValidationError) as exc_info:
ProductCreateRequest(**data)
errors = exc_info.value.errors()
assert any(error["loc"] == ("name",) for error in errors)
@pytest.mark.unit
def test_image_url_too_long(self):
"""Test request with image_url exceeding max length"""
data = {
"name": "Test Product",
"price": 19.99,
"image_url": "A" * 501, # Exceeds 500 character limit
}
with pytest.raises(ValidationError) as exc_info:
ProductCreateRequest(**data)
errors = exc_info.value.errors()
assert any(error["loc"] == ("image_url",) for error in errors)
@pytest.mark.unit
def test_price_string_conversion(self):
"""Test price string to Decimal conversion"""
data = {"name": "Test Product", "price": "29.99"}
product = ProductCreateRequest(**data)
assert product.price == Decimal("29.99")
@pytest.mark.unit
def test_stock_string_conversion(self):
"""Test stock string to int conversion"""
data = {"name": "Test Product", "price": 19.99, "stock": "10"}
product = ProductCreateRequest(**data)
assert product.stock == 10
assert isinstance(product.stock, int)
class TestProductResponseSchema:
"""Test ProductResponse schema"""
@pytest.mark.unit
def test_valid_product_response(self):
"""Test valid product response"""
data = {
"id": 1,
"name": "Test Product",
"description": "A test product",
"price": 45.99,
"stock": 10,
"image_url": "https://example.com/product.jpg",
"is_active": True,
"created_at": "2024-01-15T10:30:00",
"updated_at": "2024-01-15T10:30:00",
}
product = ProductResponse(**data)
assert product.id == 1
assert product.name == "Test Product"
assert product.price == 45.99
assert product.stock == 10
assert product.is_active is True
@pytest.mark.unit
def test_product_response_with_none_fields(self):
"""Test product response with optional None fields"""
data = {
"id": 1,
"name": "Test Product",
"price": 19.99,
"stock": 0,
"is_active": True,
}
product = ProductResponse(**data)
assert product.description is None
assert product.image_url is None
assert product.created_at is None
assert product.updated_at is None
@pytest.mark.unit
def test_model_validate_from_sqlalchemy(self, db_session):
"""Test validating SQLAlchemy model to Pydantic schema"""
from app.models import Product
db_product = Product(
name="Test Product",
description="A test product",
price=Decimal("45.99"),
stock=10,
)
db_session.add(db_product)
db_session.commit()
# Validate using model_validate (for SQLAlchemy models)
response = ProductResponse.model_validate(db_product)
assert response.name == "Test Product"
assert response.price == 45.99
assert response.stock == 10
@pytest.mark.unit
def test_model_dump(self):
"""Test model_dump method"""
data = {
"id": 1,
"name": "Test Product",
"price": 19.99,
"stock": 5,
"is_active": True,
}
product = ProductResponse(**data)
dumped = product.model_dump()
assert isinstance(dumped, dict)
assert dumped["id"] == 1
assert dumped["name"] == "Test Product"
assert dumped["price"] == 19.99
@pytest.mark.unit
def test_model_dump_json(self):
"""Test model_dump_json method"""
data = {
"id": 1,
"name": "Test Product",
"price": 19.99,
"stock": 5,
"is_active": True,
}
product = ProductResponse(**data)
json_str = product.model_dump_json()
assert isinstance(json_str, str)
assert "Test Product" in json_str

View file

@ -133,7 +133,7 @@ export function KanbanColumn({
>
<div
ref={setNodeRef}
className={`min-h-[200px] flex-1 transition-colors ${isOver ? 'bg-gray-750' : ''} maxc-h-[calc(100vh-280px)] overflow-auto scrollbar-custom px-4`}
className={`min-h-[200px] flex-1 transition-colors ${isOver ? 'bg-gray-750' : ''} maxc-h-[calc(100vh-280px)] overflow-auto scrollbar-custom pl-4 pr-2`}
>
{cards.map((card) => (
<KanbanCard key={card.id} card={card} onOpenModal={() => onOpenCardModal(card)} />

View file

@ -1,5 +1,5 @@
import axios from 'axios';
import { RegisterData, UserData, ProductData, OrderData, AuthResponse } from '../types';
import { RegisterData, UserData, AuthResponse } from '../types';
import {
Board,
BoardWithDetails,
@ -69,44 +69,6 @@ export function useApi() {
return response.data;
},
// Products
getProducts: async (): Promise<ProductData[]> => {
const response = await api.get<ProductData[]>('/products');
return response.data;
},
getProduct: async (id: string): Promise<ProductData> => {
const response = await api.get<ProductData>(`/products/${id}`);
return response.data;
},
createProduct: async (productData: Omit<ProductData, 'id'>): Promise<ProductData> => {
const response = await api.post<ProductData>('/products', productData);
return response.data;
},
updateProduct: async (
id: string,
productData: Partial<ProductData>
): Promise<ProductData> => {
const response = await api.put<ProductData>(`/products/${id}`, productData);
return response.data;
},
deleteProduct: async (id: string): Promise<void> => {
await api.delete(`/products/${id}`);
},
// Orders
getOrders: async (): Promise<OrderData[]> => {
const response = await api.get<OrderData[]>('/orders');
return response.data;
},
getOrder: async (id: string): Promise<OrderData> => {
const response = await api.get<OrderData>(`/orders/${id}`);
return response.data;
},
createOrder: async (orderData: Omit<OrderData, 'id'>): Promise<OrderData> => {
const response = await api.post<OrderData>('/orders', orderData);
return response.data;
},
// Boards
getBoards: async (): Promise<Board[]> => {
const response = await api.get<Board[]>('/boards');

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

@ -1,122 +0,0 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useApp } from '../context/AppContext';
import { useApi } from '../hooks/useApi';
import { OrderData } from '../types';
export function Orders() {
const [orders, setOrders] = useState<OrderData[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const navigate = useNavigate();
const { user } = useApp();
const { getOrders } = useApi();
useEffect(() => {
if (!user) {
navigate('/login');
return;
}
fetchOrders();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user, navigate]);
const fetchOrders = async () => {
try {
const data = await getOrders();
setOrders(data);
} catch (error) {
console.error('Error fetching orders:', error);
} finally {
setLoading(false);
}
};
const getStatusColor = (status: string): string => {
const colors: Record<string, string> = {
pending: 'bg-yellow-900 text-yellow-200 border-yellow-700',
processing: 'bg-blue-900 text-blue-200 border-blue-700',
shipped: 'bg-purple-900 text-purple-200 border-purple-700',
delivered: 'bg-green-900 text-green-200 border-green-700',
cancelled: 'bg-red-900 text-red-200 border-red-700',
};
return colors[status] || 'bg-gray-900 text-gray-200 border-gray-700';
};
if (loading) {
return (
<div className="text-center py-12">
<div className="text-gray-400">Loading orders...</div>
</div>
);
}
return (
<div>
<h1 className="text-3xl font-bold text-white mb-8">My Orders</h1>
{orders.length === 0 ? (
<div className="text-center py-12">
<p className="text-gray-400 mb-8">You have no orders yet</p>
<button
onClick={() => navigate('/products')}
className="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-lg transition-colors"
>
Browse Products
</button>
</div>
) : (
<div className="space-y-6">
{orders.map((order) => (
<div
key={order.id}
className="bg-gray-800 rounded-lg border border-gray-700 overflow-hidden"
>
<div className="p-4 border-b border-gray-700 flex justify-between items-center">
<div>
<h3 className="text-lg font-semibold text-white">Order #{order.id}</h3>
<p className="text-sm text-gray-400">
{new Date(order.created_at).toLocaleDateString()}
</p>
</div>
<span
className={`px-3 py-1 rounded-full text-sm font-medium border ${getStatusColor(
order.status
)}`}
>
{order.status.charAt(0).toUpperCase() + order.status.slice(1)}
</span>
</div>
<div className="p-4">
{order.items.map((item) => (
<div
key={item.id}
className="flex justify-between items-center py-2 border-b border-gray-700 last:border-b-0"
>
<div>
<p className="text-white font-medium">Product #{item.product_id}</p>
<p className="text-sm text-gray-400">Quantity: {item.quantity}</p>
</div>
<p className="text-white font-bold">
${(item.price * item.quantity).toFixed(2)}
</p>
</div>
))}
</div>
<div className="p-4 bg-gray-750 border-t border-gray-700 flex justify-between items-center">
<div className="text-sm text-gray-400">
{order.shipping_address && <span>Ship to: {order.shipping_address}</span>}
</div>
<div className="text-xl">
<span className="text-gray-400">Total:</span>{' '}
<span className="text-white font-bold">${order.total_amount}</span>
</div>
</div>
</div>
))}
</div>
)}
</div>
);
}

View file

@ -1,66 +0,0 @@
import { useApp } from '../context/AppContext';
import { useProducts } from '../hooks/useProducts';
import { CartItem } from '../types';
export function Products() {
const { products, refetch } = useProducts();
const { addToCart } = useApp();
return (
<div>
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold text-white">Products</h1>
<button
onClick={() => refetch()}
className="bg-gray-700 hover:bg-gray-600 text-white px-4 py-2 rounded-lg transition-colors"
>
Refresh
</button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{products.map((product) => (
<div
key={product.id}
className="bg-gray-800 rounded-lg overflow-hidden border border-gray-700 hover:border-blue-500 transition-colors"
>
{product.image_url && (
<img
src={product.image_url}
alt={product.name}
className="w-full h-48 object-cover"
/>
)}
<div className="p-4">
<h3 className="text-lg font-semibold text-white mb-2">{product.name}</h3>
<p className="text-gray-400 text-sm mb-3 line-clamp-2">{product.description}</p>
<div className="flex items-center justify-between">
<span className="text-xl font-bold text-blue-400">${product.price}</span>
<span className="text-sm text-gray-400">Stock: {product.stock}</span>
</div>
<button
onClick={() => {
const cartItem: CartItem = {
id: parseInt(product.id!),
name: product.name,
price: product.price,
quantity: 1,
image_url: product.image_url,
};
addToCart(cartItem);
}}
className="mt-4 w-full bg-blue-600 hover:bg-blue-700 text-white py-2 px-4 rounded-lg transition-colors"
>
Add to Cart
</button>
</div>
</div>
))}
</div>
{products.length === 0 && (
<div className="text-center py-12">
<p className="text-gray-400">No products available</p>
</div>
)}
</div>
);
}

View file

@ -1,24 +0,0 @@
export interface OrderItem {
id: string;
product_id: string;
quantity: number;
price: number;
}
export interface OrderData {
id: string;
status: 'pending' | 'processing' | 'shipped' | 'delivered' | 'cancelled';
total_amount: number;
created_at: string;
shipping_address?: string;
items: OrderItem[];
}
export interface Order {
id: number;
created_at: string;
status: string;
total_amount: number;
shipping_address: string;
items: OrderItem[];
}

View file

@ -1,26 +0,0 @@
export interface Product {
id: string;
name: string;
description: string;
price: number;
stock: number;
image_url?: string;
}
export interface ProductData {
id?: string;
name: string;
description: string;
price: number;
stock: number;
image_url?: string;
}
export interface CartItem {
id: number;
name: string;
price: number;
quantity: number;
image_url?: string;
[key: string]: any;
}

View file

@ -21,7 +21,6 @@
"noFallthroughCasesInSwitch": true,
/* Path Aliases */
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}