architect/research/PM_IMPLEMENTATION_EXAMPLES.md

PM System Implementation Examples

Практические примеры реализации основных компонентов PM системы.


1. Database Schema (PostgreSQL)

Основные таблицы

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

2. API Implementation (FastAPI + SQLAlchemy)

Models (SQLAlchemy ORM)

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

Schemas (Pydantic)

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

API Routes

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

3. Frontend Component Examples (React + TypeScript)

TaskCard Component (для Board view)

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

CommentThread Component

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>');
}

4. Notification System

WebSocket for Real-time Updates

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)

Email Notifications

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(),
    })

5. Search Implementation

Full-Text Search with PostgreSQL

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