Практические примеры реализации основных компонентов PM системы.
-- Workspaces & Organizations
CREATE TABLE workspaces (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
slug VARCHAR(255) UNIQUE NOT NULL,
owner_id UUID NOT NULL REFERENCES users(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
archived_at TIMESTAMP
);
-- Projects
CREATE TABLE projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
description TEXT,
-- Configuration
default_view VARCHAR(50) DEFAULT 'list', -- list, board, gantt, calendar
color VARCHAR(7), -- hex color
-- Metadata
owner_id UUID NOT NULL REFERENCES users(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
archived_at TIMESTAMP,
UNIQUE(workspace_id, name)
);
-- Tasks (основная таблица)
CREATE TABLE tasks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
-- Content
title VARCHAR(500) NOT NULL,
description TEXT,
-- Status & Priority
status VARCHAR(50) NOT NULL DEFAULT 'todo', -- backlog, todo, in_progress, in_review, done
priority VARCHAR(50) DEFAULT 'medium', -- low, medium, high, urgent
-- People
creator_id UUID NOT NULL REFERENCES users(id),
-- Hierarchy & Relations
parent_task_id UUID REFERENCES tasks(id) ON DELETE SET NULL,
-- Dates
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
due_date DATE,
start_date DATE,
completed_at TIMESTAMP,
archived_at TIMESTAMP,
-- Metadata
estimate_points INT,
actual_time INT, -- в минутах
-- Full-text search
search_vector tsvector GENERATED ALWAYS AS (
to_tsvector('english', title || ' ' || COALESCE(description, ''))
) STORED,
INDEX idx_project_tasks (project_id),
INDEX idx_status (status),
INDEX idx_due_date (due_date),
INDEX idx_parent_task (parent_task_id),
INDEX idx_creator (creator_id),
INDEX idx_search (search_vector) USING gin
);
-- Task Assignees (M:M)
CREATE TABLE task_assignees (
task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
assigned_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
assigned_by_id UUID NOT NULL REFERENCES users(id),
PRIMARY KEY (task_id, user_id),
INDEX idx_user_tasks (user_id)
);
-- Task Labels/Tags (M:M)
CREATE TABLE labels (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
color VARCHAR(7),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(project_id, name)
);
CREATE TABLE task_labels (
task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
label_id UUID NOT NULL REFERENCES labels(id) ON DELETE CASCADE,
PRIMARY KEY (task_id, label_id)
);
-- Task Dependencies
CREATE TABLE task_dependencies (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
source_task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
target_task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
-- Dependency type: finish-to-start (FS), start-to-start (SS), etc.
dependency_type VARCHAR(20) DEFAULT 'finish-to-start',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(source_task_id, target_task_id),
INDEX idx_source (source_task_id),
INDEX idx_target (target_task_id)
);
-- Comments
CREATE TABLE comments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
content TEXT NOT NULL,
creator_id UUID NOT NULL REFERENCES users(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
parent_comment_id UUID REFERENCES comments(id) ON DELETE SET NULL,
archived_at TIMESTAMP,
INDEX idx_task_comments (task_id),
INDEX idx_creator (creator_id),
INDEX idx_parent_comment (parent_comment_id)
);
-- Comment Reactions (emoji reactions)
CREATE TABLE comment_reactions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
comment_id UUID NOT NULL REFERENCES comments(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
emoji VARCHAR(50) NOT NULL, -- '👍', '❤️', etc.
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(comment_id, user_id, emoji),
INDEX idx_comment_reactions (comment_id)
);
-- Attachments
CREATE TABLE attachments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
file_name VARCHAR(500) NOT NULL,
file_size BIGINT NOT NULL,
mime_type VARCHAR(100),
s3_key VARCHAR(1000) NOT NULL, -- path in S3
s3_url VARCHAR(2000) NOT NULL, -- public URL
uploaded_by_id UUID NOT NULL REFERENCES users(id),
uploaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
version INT DEFAULT 1,
INDEX idx_task_attachments (task_id),
INDEX idx_uploader (uploaded_by_id)
);
-- Activity Log (аудит и история)
CREATE TABLE activity_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
actor_id UUID NOT NULL REFERENCES users(id),
action VARCHAR(100) NOT NULL, -- task_created, status_changed, etc.
old_value TEXT,
new_value TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
-- Fast queries by task and time
INDEX idx_task_activity (task_id, created_at DESC),
INDEX idx_actor (actor_id),
INDEX idx_action (action),
INDEX idx_created_at (created_at DESC)
);
-- Task Watchers (люди, следящие за задачей)
CREATE TABLE task_watchers (
task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
PRIMARY KEY (task_id, user_id),
INDEX idx_user_watches (user_id)
);
-- Custom Fields Configuration
CREATE TABLE custom_fields (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
field_type VARCHAR(50) NOT NULL, -- text, number, select, date, person, etc.
-- Configuration as JSON
config JSONB, -- { options: ['option1', 'option2'], required: true, ... }
position INT, -- order in UI
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(project_id, name),
INDEX idx_project_fields (project_id)
);
-- Custom Field Values
CREATE TABLE custom_field_values (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
custom_field_id UUID NOT NULL REFERENCES custom_fields(id) ON DELETE CASCADE,
-- Store value as JSONB for flexibility
value JSONB,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(task_id, custom_field_id),
INDEX idx_task_fields (task_id),
INDEX idx_field (custom_field_id)
);
-- Project Workflow Configuration
CREATE TABLE workflow_definitions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL DEFAULT 'Default',
-- List of statuses and transitions as JSON
statuses JSONB NOT NULL, -- [{ id: 'todo', label: 'To Do', color: '#...' }, ...]
transitions JSONB NOT NULL, -- [{ from: 'todo', to: 'in_progress' }, ...]
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Fast queries for common filters
CREATE INDEX idx_tasks_project_status ON tasks(project_id, status);
CREATE INDEX idx_tasks_project_due_date ON tasks(project_id, due_date);
CREATE INDEX idx_tasks_assignee ON task_assignees(user_id);
-- Activity and audit
CREATE INDEX idx_activity_task_time ON activity_logs(task_id, created_at DESC);
-- Full-text search
CREATE INDEX idx_tasks_search ON tasks USING gin(search_vector);
-- Denormalization for performance (optional)
CREATE MATERIALIZED VIEW task_summary AS
SELECT
t.id,
t.project_id,
t.title,
t.status,
COUNT(DISTINCT ta.user_id) as assignee_count,
COUNT(DISTINCT c.id) as comment_count,
COUNT(DISTINCT att.id) as attachment_count,
ARRAY_AGG(DISTINCT l.name) as labels
FROM tasks t
LEFT JOIN task_assignees ta ON t.id = ta.task_id
LEFT JOIN comments c ON t.id = c.task_id AND c.archived_at IS NULL
LEFT JOIN attachments att ON t.id = att.task_id
LEFT JOIN task_labels tl ON t.id = tl.task_id
LEFT JOIN labels l ON tl.label_id = l.id
WHERE t.archived_at IS NULL
GROUP BY t.id;
CREATE INDEX idx_task_summary_project ON task_summary(project_id);
from typing import List, Optional
from datetime import datetime
from sqlalchemy import Column, String, UUID, DateTime, Integer, Text, ForeignKey, Table, Enum as SQLEnum, JSON
from sqlalchemy.orm import relationship
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
import uuid
import enum
class TaskStatus(str, enum.Enum):
BACKLOG = "backlog"
TODO = "todo"
IN_PROGRESS = "in_progress"
IN_REVIEW = "in_review"
DONE = "done"
ARCHIVED = "archived"
class Priority(str, enum.Enum):
LOW = "low"
MEDIUM = "medium"
HIGH = "high"
URGENT = "urgent"
# Association tables for M:M relationships
task_assignees = Table(
'task_assignees',
Base.metadata,
Column('task_id', PG_UUID(as_uuid=True), ForeignKey('tasks.id')),
Column('user_id', PG_UUID(as_uuid=True), ForeignKey('users.id')),
)
task_labels_table = Table(
'task_labels',
Base.metadata,
Column('task_id', PG_UUID(as_uuid=True), ForeignKey('tasks.id')),
Column('label_id', PG_UUID(as_uuid=True), ForeignKey('labels.id')),
)
class Task(Base):
__tablename__ = "tasks"
id: UUID = Column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
project_id: UUID = Column(PG_UUID(as_uuid=True), ForeignKey('projects.id'), nullable=False)
title: str = Column(String(500), nullable=False)
description: Optional[str] = Column(Text)
status: TaskStatus = Column(SQLEnum(TaskStatus), default=TaskStatus.TODO)
priority: Priority = Column(SQLEnum(Priority), default=Priority.MEDIUM)
creator_id: UUID = Column(PG_UUID(as_uuid=True), ForeignKey('users.id'), nullable=False)
parent_task_id: Optional[UUID] = Column(PG_UUID(as_uuid=True), ForeignKey('tasks.id'))
due_date: Optional[datetime] = Column(DateTime)
start_date: Optional[datetime] = Column(DateTime)
completed_at: Optional[datetime] = Column(DateTime)
created_at: datetime = Column(DateTime, default=datetime.utcnow)
updated_at: datetime = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
archived_at: Optional[datetime] = Column(DateTime)
estimate_points: Optional[int] = Column(Integer)
# Relationships
project: relationship("Project", back_populates="tasks")
creator: relationship("User", foreign_keys=[creator_id])
assignees: relationship("User", secondary=task_assignees, back_populates="assigned_tasks")
labels: relationship("Label", secondary=task_labels_table, back_populates="tasks")
comments: relationship("Comment", back_populates="task", cascade="all, delete-orphan")
attachments: relationship("Attachment", back_populates="task", cascade="all, delete-orphan")
activity_logs: relationship("ActivityLog", back_populates="task", cascade="all, delete-orphan")
watchers: relationship("User", secondary="task_watchers", back_populates="watched_tasks")
dependencies: relationship(
"Task",
secondary="task_dependencies",
primaryjoin="Task.id==task_dependencies.c.source_task_id",
secondaryjoin="Task.id==task_dependencies.c.target_task_id",
backref="dependents"
)
subtasks: relationship("Task", remote_side=[parent_task_id], back_populates="parent_task")
parent_task: relationship("Task", remote_side=[parent_task_id], back_populates="subtasks")
class Comment(Base):
__tablename__ = "comments"
id: UUID = Column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
task_id: UUID = Column(PG_UUID(as_uuid=True), ForeignKey('tasks.id'), nullable=False)
content: str = Column(Text, nullable=False)
creator_id: UUID = Column(PG_UUID(as_uuid=True), ForeignKey('users.id'), nullable=False)
parent_comment_id: Optional[UUID] = Column(PG_UUID(as_uuid=True), ForeignKey('comments.id'))
created_at: datetime = Column(DateTime, default=datetime.utcnow)
updated_at: datetime = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
archived_at: Optional[datetime] = Column(DateTime)
# Relationships
task: relationship("Task", back_populates="comments")
creator: relationship("User", back_populates="comments")
replies: relationship("Comment", remote_side=[parent_comment_id], back_populates="parent")
parent: relationship("Comment", remote_side=[parent_comment_id], back_populates="replies")
reactions: relationship("CommentReaction", back_populates="comment", cascade="all, delete-orphan")
class ActivityLog(Base):
__tablename__ = "activity_logs"
id: UUID = Column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
task_id: UUID = Column(PG_UUID(as_uuid=True), ForeignKey('tasks.id'), nullable=False)
actor_id: UUID = Column(PG_UUID(as_uuid=True), ForeignKey('users.id'), nullable=False)
action: str = Column(String(100), nullable=False)
old_value: Optional[str] = Column(Text)
new_value: Optional[str] = Column(Text)
created_at: datetime = Column(DateTime, default=datetime.utcnow)
# Relationships
task: relationship("Task", back_populates="activity_logs")
actor: relationship("User")
from pydantic import BaseModel, Field
from typing import List, Optional
from datetime import datetime
from enum import Enum
class TaskStatus(str, Enum):
BACKLOG = "backlog"
TODO = "todo"
IN_PROGRESS = "in_progress"
IN_REVIEW = "in_review"
DONE = "done"
class TaskCreate(BaseModel):
title: str
description: Optional[str] = None
status: TaskStatus = TaskStatus.TODO
priority: str = "medium"
assignees: Optional[List[str]] = None # User IDs
due_date: Optional[datetime] = None
labels: Optional[List[str]] = None # Label IDs
parent_task_id: Optional[str] = None
class TaskUpdate(BaseModel):
title: Optional[str] = None
description: Optional[str] = None
status: Optional[TaskStatus] = None
priority: Optional[str] = None
assignees: Optional[List[str]] = None
due_date: Optional[datetime] = None
labels: Optional[List[str]] = None
class TaskResponse(BaseModel):
id: str
project_id: str
title: str
description: Optional[str]
status: TaskStatus
priority: str
assignees: List[dict] # { id, name, avatar }
labels: List[dict] # { id, name, color }
due_date: Optional[datetime]
completed_at: Optional[datetime]
created_at: datetime
updated_at: datetime
comment_count: int
attachment_count: int
class Config:
from_attributes = True
class CommentCreate(BaseModel):
content: str
parent_comment_id: Optional[str] = None
class CommentResponse(BaseModel):
id: str
task_id: str
content: str
creator: dict # { id, name, avatar }
created_at: datetime
updated_at: datetime
replies: Optional[List['CommentResponse']] = None
reactions: Optional[List[dict]] = None # { emoji, count, user_reacted }
class ActivityResponse(BaseModel):
id: str
actor: dict # { id, name, avatar }
action: str
old_value: Optional[str]
new_value: Optional[str]
created_at: datetime
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from typing import List
router = APIRouter(prefix="/api/projects/{project_id}/tasks", tags=["tasks"])
# Get all tasks with filtering
@router.get("/")
async def list_tasks(
project_id: str,
status: Optional[str] = Query(None),
assignee: Optional[str] = Query(None),
search: Optional[str] = Query(None),
limit: int = Query(20, le=100),
offset: int = Query(0),
db: Session = Depends(get_db),
):
"""Get tasks with filtering, searching, and pagination."""
query = db.query(Task).filter(Task.project_id == project_id)
if status:
query = query.filter(Task.status == status)
if assignee:
query = query.join(task_assignees).filter(
task_assignees.c.user_id == assignee
)
if search:
# Use full-text search from database
query = query.filter(Task.search_vector.isoquery(search, type_='websearch'))
# Filter out archived
query = query.filter(Task.archived_at.is_(None))
# Pagination
total = query.count()
tasks = query.order_by(Task.created_at.desc()).offset(offset).limit(limit).all()
return {
"total": total,
"tasks": [TaskResponse.from_orm(t) for t in tasks],
"limit": limit,
"offset": offset,
}
# Get single task
@router.get("/{task_id}")
async def get_task(
project_id: str,
task_id: str,
include: Optional[str] = Query(None), # comments,attachments,activity
db: Session = Depends(get_db),
):
"""Get task details with optional related data."""
task = db.query(Task).filter(
Task.id == task_id,
Task.project_id == project_id,
).first()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
response = TaskResponse.from_orm(task)
if include:
includes = include.split(",")
if "comments" in includes:
response.comments = [CommentResponse.from_orm(c) for c in task.comments]
if "activity" in includes:
response.activity = [ActivityResponse.from_orm(a) for a in task.activity_logs]
if "attachments" in includes:
response.attachments = [AttachmentResponse.from_orm(a) for a in task.attachments]
return response
# Create task
@router.post("/")
async def create_task(
project_id: str,
task_data: TaskCreate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Create a new task."""
# Verify project exists
project = db.query(Project).filter(Project.id == project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
# Create task
task = Task(
project_id=project_id,
title=task_data.title,
description=task_data.description,
status=task_data.status,
priority=task_data.priority,
due_date=task_data.due_date,
creator_id=current_user.id,
)
# Add assignees
if task_data.assignees:
assignees = db.query(User).filter(User.id.in_(task_data.assignees)).all()
task.assignees = assignees
# Add labels
if task_data.labels:
labels = db.query(Label).filter(Label.id.in_(task_data.labels)).all()
task.labels = labels
db.add(task)
db.commit()
# Log activity
log_activity(db, task.id, current_user.id, "task_created", None, task.id)
return TaskResponse.from_orm(task)
# Update task
@router.patch("/{task_id}")
async def update_task(
project_id: str,
task_id: str,
task_data: TaskUpdate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Update task."""
task = db.query(Task).filter(
Task.id == task_id,
Task.project_id == project_id,
).first()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
# Update fields and log changes
for field, value in task_data.dict(exclude_unset=True).items():
if value is not None:
old_value = getattr(task, field)
setattr(task, field, value)
# Log to activity
log_activity(db, task_id, current_user.id, f"{field}_changed", old_value, value)
# Update timestamp
task.updated_at = datetime.utcnow()
db.commit()
return TaskResponse.from_orm(task)
# Add comment
@router.post("/{task_id}/comments")
async def add_comment(
project_id: str,
task_id: str,
comment_data: CommentCreate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Add comment to task."""
task = db.query(Task).filter(
Task.id == task_id,
Task.project_id == project_id,
).first()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
comment = Comment(
task_id=task_id,
content=comment_data.content,
creator_id=current_user.id,
parent_comment_id=comment_data.parent_comment_id,
)
db.add(comment)
db.commit()
# Log activity
log_activity(db, task_id, current_user.id, "comment_added", None, comment.id)
# Send notifications to watchers
notify_watchers(task, f"New comment by {current_user.name}", comment)
return CommentResponse.from_orm(comment)
# Get activity log
@router.get("/{task_id}/activity")
async def get_activity(
project_id: str,
task_id: str,
limit: int = Query(50, le=100),
offset: int = Query(0),
db: Session = Depends(get_db),
):
"""Get task activity log."""
task = db.query(Task).filter(
Task.id == task_id,
Task.project_id == project_id,
).first()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
activities = db.query(ActivityLog).filter(
ActivityLog.task_id == task_id
).order_by(
ActivityLog.created_at.desc()
).offset(offset).limit(limit).all()
return [ActivityResponse.from_orm(a) for a in activities]
def log_activity(db: Session, task_id: str, actor_id: str, action: str, old_value, new_value):
"""Helper to log activity."""
log = ActivityLog(
task_id=task_id,
actor_id=actor_id,
action=action,
old_value=str(old_value) if old_value else None,
new_value=str(new_value) if new_value else None,
)
db.add(log)
db.flush()
import React, { useState } from 'react';
import { Task, User } from '@/types';
import styles from './TaskCard.module.css';
interface TaskCardProps {
task: Task;
onDragStart?: (e: React.DragEvent) => void;
onClick?: () => void;
onStatusChange?: (status: string) => void;
}
export const TaskCard: React.FC<TaskCardProps> = ({
task,
onDragStart,
onClick,
onStatusChange,
}) => {
const [isHovering, setIsHovering] = useState(false);
return (
<div
className={styles.card}
draggable
onDragStart={onDragStart}
onClick={onClick}
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
>
{/* Priority indicator */}
<div className={`${styles.priorityBadge} ${styles[`priority-${task.priority}`]}`}>
{task.priority[0].toUpperCase()}
</div>
{/* Title */}
<h3 className={styles.title}>{task.title}</h3>
{/* Description preview */}
{task.description && (
<p className={styles.description}>{task.description.substring(0, 100)}...</p>
)}
{/* Labels */}
{task.labels && task.labels.length > 0 && (
<div className={styles.labels}>
{task.labels.slice(0, 2).map((label) => (
<span key={label.id} className={styles.label} style={{ backgroundColor: label.color }}>
{label.name}
</span>
))}
{task.labels.length > 2 && (
<span className={styles.label}>+{task.labels.length - 2}</span>
)}
</div>
)}
{/* Footer with metadata */}
<div className={styles.footer}>
{/* Due date */}
{task.dueDate && (
<span className={`${styles.dueDate} ${isOverdue(task.dueDate) ? styles.overdue : ''}`}>
📅 {formatDate(task.dueDate)}
</span>
)}
{/* Metadata counts */}
<div className={styles.metadata}>
{task.commentCount > 0 && (
<span className={styles.metaItem}>
💬 {task.commentCount}
</span>
)}
{task.attachmentCount > 0 && (
<span className={styles.metaItem}>
📎 {task.attachmentCount}
</span>
)}
</div>
{/* Assignees avatars */}
{task.assignees && task.assignees.length > 0 && (
<div className={styles.assignees}>
{task.assignees.slice(0, 3).map((user) => (
<img
key={user.id}
className={styles.avatar}
src={user.avatar}
alt={user.name}
title={user.name}
/>
))}
{task.assignees.length > 3 && (
<span className={styles.moreAssignees}>+{task.assignees.length - 3}</span>
)}
</div>
)}
</div>
</div>
);
};
function isOverdue(dueDate: string): boolean {
return new Date(dueDate) < new Date();
}
function formatDate(date: string): string {
return new Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric',
}).format(new Date(date));
}
import React, { useState } from 'react';
import { Comment, User } from '@/types';
import styles from './CommentThread.module.css';
interface CommentThreadProps {
taskId: string;
comments: Comment[];
onAddComment: (content: string, parentId?: string) => Promise<void>;
currentUser: User;
}
export const CommentThread: React.FC<CommentThreadProps> = ({
taskId,
comments,
onAddComment,
currentUser,
}) => {
const [isLoading, setIsLoading] = useState(false);
const [inputValue, setInputValue] = useState('');
const [replyingTo, setReplyingTo] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!inputValue.trim()) return;
setIsLoading(true);
try {
await onAddComment(inputValue, replyingTo || undefined);
setInputValue('');
setReplyingTo(null);
} finally {
setIsLoading(false);
}
};
return (
<div className={styles.thread}>
{/* Comments list */}
<div className={styles.commentsList}>
{comments.map((comment) => (
<CommentItem
key={comment.id}
comment={comment}
onReply={() => setReplyingTo(comment.id)}
isReplyingTo={replyingTo === comment.id}
/>
))}
</div>
{/* Comment input */}
<form onSubmit={handleSubmit} className={styles.inputForm}>
<img
className={styles.avatar}
src={currentUser.avatar}
alt={currentUser.name}
/>
<div className={styles.inputContainer}>
{replyingTo && (
<div className={styles.replyingTo}>
Replying to {comments.find((c) => c.id === replyingTo)?.creator.name}
<button
type="button"
onClick={() => setReplyingTo(null)}
className={styles.cancelButton}
>
✕
</button>
</div>
)}
<textarea
placeholder="Add a comment... (Cmd+Enter to submit)"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
handleSubmit(e as any);
}
}}
className={styles.textarea}
/>
<div className={styles.actions}>
<button
type="submit"
disabled={isLoading || !inputValue.trim()}
className={styles.submitButton}
>
{isLoading ? 'Sending...' : 'Send'}
</button>
</div>
</div>
</form>
</div>
);
};
interface CommentItemProps {
comment: Comment;
onReply: () => void;
isReplyingTo: boolean;
}
const CommentItem: React.FC<CommentItemProps> = ({ comment, onReply, isReplyingTo }) => {
const [showReplies, setShowReplies] = useState(true);
return (
<div className={styles.commentItem}>
<img
className={styles.avatar}
src={comment.creator.avatar}
alt={comment.creator.name}
/>
<div className={styles.content}>
<div className={styles.header}>
<strong>{comment.creator.name}</strong>
<span className={styles.timestamp}>{formatTime(comment.createdAt)}</span>
</div>
<p className={styles.text}>{parseContent(comment.content)}</p>
{/* Reactions */}
{comment.reactions && comment.reactions.length > 0 && (
<div className={styles.reactions}>
{comment.reactions.map((reaction) => (
<span key={reaction.emoji} className={styles.reaction}>
{reaction.emoji} {reaction.count}
</span>
))}
</div>
)}
{/* Actions */}
<div className={styles.actions}>
<button className={styles.actionButton} onClick={onReply}>
Reply
</button>
<button className={styles.actionButton}>React</button>
{comment.updatedAt !== comment.createdAt && (
<span className={styles.edited}>(edited)</span>
)}
</div>
{/* Replies */}
{comment.replies && comment.replies.length > 0 && (
<div className={styles.replies}>
{showReplies && (
comment.replies.map((reply) => (
<CommentItem
key={reply.id}
comment={reply}
onReply={onReply}
isReplyingTo={false}
/>
))
)}
<button
className={styles.toggleReplies}
onClick={() => setShowReplies(!showReplies)}
>
{showReplies ? '−' : '+'} {comment.replies.length} replies
</button>
</div>
)}
</div>
</div>
);
};
function formatTime(date: string): string {
const now = new Date();
const commentDate = new Date(date);
const diff = now.getTime() - commentDate.getTime();
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (minutes < 60) return `${minutes}m ago`;
if (hours < 24) return `${hours}h ago`;
if (days < 7) return `${days}d ago`;
return commentDate.toLocaleDateString();
}
function parseContent(content: string): React.ReactNode {
// Simple markdown-like parsing for @mentions and links
return content.replace(/@(\w+)/g, '<strong>@$1</strong>');
}
from fastapi import WebSocket
from typing import Set
import json
from datetime import datetime
class ConnectionManager:
def __init__(self):
self.active_connections: dict[str, list[WebSocket]] = {}
async def connect(self, task_id: str, websocket: WebSocket):
await websocket.accept()
if task_id not in self.active_connections:
self.active_connections[task_id] = []
self.active_connections[task_id].append(websocket)
def disconnect(self, task_id: str, websocket: WebSocket):
if task_id in self.active_connections:
self.active_connections[task_id].remove(websocket)
async def broadcast_to_task(self, task_id: str, message: dict):
"""Broadcast message to all clients watching a task."""
if task_id not in self.active_connections:
return
for connection in self.active_connections[task_id]:
try:
await connection.send_json(message)
except RuntimeError:
# Connection closed
pass
async def notify_user(self, user_id: str, notification: dict):
"""Send notification to specific user."""
# This would require mapping user_id to their WebSocket connections
pass
manager = ConnectionManager()
@app.websocket("/ws/tasks/{task_id}")
async def websocket_endpoint(websocket: WebSocket, task_id: str):
await manager.connect(task_id, websocket)
try:
while True:
# Listen for incoming messages (e.g., typing indicator)
data = await websocket.receive_text()
# Could echo back typing indicators, etc.
except Exception as e:
manager.disconnect(task_id, websocket)
from celery import Celery
from jinja2 import Template
import smtplib
celery_app = Celery('tasks', broker='redis://localhost:6379')
@celery_app.task
def send_notification_email(
user_id: str,
notification_type: str,
task_id: str,
data: dict,
):
"""Send email notification for task events."""
user = db.query(User).filter(User.id == user_id).first()
if not user or not user.email:
return
task = db.query(Task).filter(Task.id == task_id).first()
if not task:
return
# Different templates based on notification type
templates = {
'comment_mentioned': 'You were mentioned in a comment on {task_title}',
'task_assigned': 'You were assigned to {task_title}',
'status_changed': 'Status changed on {task_title}',
'due_date_approaching': '{task_title} is due soon',
}
subject = templates.get(notification_type, 'New notification').format(
task_title=task.title
)
# Render email template
email_template = open('templates/notification_email.html').read()
html = Template(email_template).render(
user_name=user.name,
task_title=task.title,
task_id=task.id,
message=data.get('message', ''),
action_url=f"https://app.example.com/tasks/{task.id}",
)
# Send email (using your preferred email service)
send_email(
to=user.email,
subject=subject,
html=html,
)
# Trigger notifications when events happen
@router.post("/{task_id}/comments")
async def add_comment(...):
# ... create comment ...
# Extract mentions
mentions = extract_mentions(comment.content) # Find @username
# Send notifications
for mentioned_user_id in mentions:
send_notification_email.delay(
user_id=mentioned_user_id,
notification_type='comment_mentioned',
task_id=task_id,
data={'message': f'Mentioned in comment'},
)
# Notify watchers
for watcher in task.watchers:
send_notification_email.delay(
user_id=watcher.id,
notification_type='comment_added',
task_id=task_id,
data={'message': f'New comment by {current_user.name}'},
)
# Real-time update via WebSocket
await manager.broadcast_to_task(task_id, {
'type': 'comment_added',
'comment': CommentResponse.from_orm(comment).dict(),
'timestamp': datetime.utcnow().isoformat(),
})
from sqlalchemy import or_, and_, func
@router.get("/search")
async def search_tasks(
project_id: str,
q: str,
db: Session = Depends(get_db),
):
"""Full-text search across tasks."""
if not q or len(q) < 2:
return {"results": []}
# Using PostgreSQL's full-text search
results = db.query(Task).filter(
Task.project_id == project_id,
Task.archived_at.is_(None),
Task.search_vector.isoquery(q, type_='websearch'),
).order_by(
# Rank by relevance
func.ts_rank(Task.search_vector, func.plainto_tsquery(q)).desc()
).limit(20).all()
return {
"results": [TaskResponse.from_orm(t) for t in results],
"query": q,
"count": len(results),
}
# Advanced search with filters
@router.get("/search/advanced")
async def advanced_search(
project_id: str,
q: Optional[str] = None,
status: Optional[str] = None,
assignee: Optional[str] = None,
label: Optional[str] = None,
dueDate_from: Optional[str] = None,
dueDate_to: Optional[str] = None,
db: Session = Depends(get_db),
):
"""Advanced search with multiple filters."""
query = db.query(Task).filter(Task.project_id == project_id)
# Text search
if q:
query = query.filter(Task.search_vector.isoquery(q, type_='websearch'))
# Status filter
if status:
query = query.filter(Task.status == status)
# Assignee filter
if assignee:
query = query.join(task_assignees).filter(task_assignees.c.user_id == assignee)
# Label filter
if label:
query = query.join(task_labels_table).filter(task_labels_table.c.label_id == label)
# Date range filter
if dueDate_from:
query = query.filter(Task.due_date >= dueDate_from)
if dueDate_to:
query = query.filter(Task.due_date <= dueDate_to)
# Filter archived
query = query.filter(Task.archived_at.is_(None))
results = query.all()
return {
"results": [TaskResponse.from_orm(t) for t in results],
"count": len(results),
}
Эти примеры показывают основные компоненты PM системы:
1. Надежная БД схема с индексами
2. RESTful API с CRUD операциями
3. React компоненты для UI
4. Real-time обновления через WebSocket
5. Поиск и фильтрация
Для production системы нужно добавить:
- Authentication & Authorization
- Rate limiting & Caching
- Monitoring & Logging
- Tests (Unit, Integration, E2E)
- Documentation (API docs, deployment guide)
- Performance tuning
- Security hardening