Newer
Older
gnexus-tasks / backend / app / models.py
"""Модели данных M1: задачи, проекты, теги.

Дедлайны/повторения/бюджет/вложения добавляются миграциями в следующих вехах
(см. docs/TZ.md, модель данных).
"""

from datetime import UTC, date, datetime
from typing import Any

from sqlalchemy import (
    JSON,
    Boolean,
    Date,
    DateTime,
    ForeignKey,
    Index,
    Integer,
    PrimaryKeyConstraint,
    String,
    Text,
    UniqueConstraint,
    false,
    func,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship

from app.db import Base


def utcnow() -> datetime:
    return datetime.now(UTC)


def _is_future(moment: datetime | None) -> bool:
    """«Момент ещё не наступил?» — терпит naive-datetime из SQLite (тесты)."""
    if moment is None:
        return False
    if moment.tzinfo is None:
        moment = moment.replace(tzinfo=UTC)
    return moment > utcnow()


class User(Base):
    """Пользователь из SSO (gnexus-gauth): профиль синхронизируется при логине.

    id — user_id из SSO (строка); локальной аутентификации нет. Все данные
    приложения привязаны к user_id; строки с NULL user_id — «бесхозные»,
    приписываются пользователю при первом логине (claim, ТЗ 1.2).
    """

    __tablename__ = "users"

    id: Mapped[str] = mapped_column(String(64), primary_key=True)
    email: Mapped[str] = mapped_column(String(255), default="")
    # Отображаемое имя из SSO (profile.display_name); None — пользователь его не задал
    name: Mapped[str | None] = mapped_column(String(120), nullable=True, default=None)
    avatar_url: Mapped[str | None] = mapped_column(String(500), nullable=True, default=None)
    locale: Mapped[str | None] = mapped_column(String(16), nullable=True, default=None)
    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)


class McpToken(Base):
    """Per-user bearer-токен MCP (ТЗ 3.10): агент действует от имени пользователя.

    Хранится только sha256-хэш; plaintext показывается один раз при генерации.
    """

    __tablename__ = "mcp_tokens"

    id: Mapped[int] = mapped_column(Integer, primary_key=True)
    user_id: Mapped[str] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
    token_hash: Mapped[str] = mapped_column(String(64), unique=True)
    label: Mapped[str] = mapped_column(String(100), default="")
    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)


class PushSubscription(Base):
    """Подписка браузера на системные уведомления (ТЗ 3.21).

    endpoint уникален глобально, а не в паре с user_id: один браузерный профиль
    может перейти к другому пользователю, и тогда подписка перепривязывается к
    нему (upsert), а не размножается. Ключи p256dh/auth — материал шифрования
    устройства, живут только здесь; сервер их не проверяет, а передаёт push-сервису.

    Мёртвые подписки (ответ 404/410 или постоянные ошибки) удаляются лениво —
    при очередной отправке, без отдельной уборки.
    """

    __tablename__ = "push_subscriptions"

    id: Mapped[int] = mapped_column(Integer, primary_key=True)
    user_id: Mapped[str] = mapped_column(
        ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
    )
    endpoint: Mapped[str] = mapped_column(String(1024), unique=True)
    p256dh: Mapped[str] = mapped_column(String(255))
    auth: Mapped[str] = mapped_column(String(255))
    # User-Agent подписки — подпись устройства в блоке настроек («это устройство»)
    user_agent: Mapped[str | None] = mapped_column(String(300), nullable=True, default=None)
    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
    last_success_at: Mapped[datetime | None] = mapped_column(
        DateTime(timezone=True), nullable=True, default=None
    )
    # Счётчик подряд неудачных отправок: накопившееся считаем мёртвой подпиской
    failure_count: Mapped[int] = mapped_column(Integer, default=0, server_default="0")


class PushDelivery(Base):
    """Журнал отправленных напоминаний (ТЗ 3.21, 0.89) — защита от дублей.

    Напоминания дневные («сегодня день D»), а планировщик тикает каждые 15 минут:
    без журнала одна и та же просрочка приходила бы сотню раз. Ключ дедупа —
    (user_id, kind, ref_key), и уникальность держит база, а не память процесса:
    перезапуск API (а он перезапускается на каждом деплое) не должен рассылать
    отправленное заново. Вставка идёт **до** отправки: уведомление, потерянное
    из-за падения между вставкой и отправкой, лучше повтора в шторке.

    Записи не чистятся — срок хранения ещё не выбран (8.14).
    """

    __tablename__ = "push_deliveries"
    __table_args__ = (
        UniqueConstraint("user_id", "kind", "ref_key", name="uq_push_deliveries_ref"),
    )

    id: Mapped[int] = mapped_column(Integer, primary_key=True)
    user_id: Mapped[str] = mapped_column(
        ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
    )
    # Вид напоминания (task.due_today, summary, …) — тот же ключ, что у текстов
    kind: Mapped[str] = mapped_column(String(40))
    # Что именно отправлено: id задачи и дата (срок / день ритма), дата сводки.
    # Id задачи обязателен у обоих видов: без него поводы разных задач одного
    # пользователя схлопывались в один — за день уходило одно «Пора» на всех (0.91)
    ref_key: Mapped[str] = mapped_column(String(120))
    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)


class Task(Base):
    __tablename__ = "tasks"
    # Индексы под новые фильтры списка задач (ТЗ 3.20): доступные агенту,
    # ждущие приёмки, созданные агентом — все запросы идут от user_id
    __table_args__ = (
        Index("ix_tasks_user_ai_eligible", "user_id", "ai_eligible"),
        Index("ix_tasks_user_accept_state", "user_id", "accept_state"),
        Index("ix_tasks_user_created_by", "user_id", "created_by_kind"),
    )

    id: Mapped[int] = mapped_column(Integer, primary_key=True)
    user_id: Mapped[str | None] = mapped_column(
        ForeignKey("users.id"), nullable=True, default=None, index=True
    )
    title: Mapped[str] = mapped_column(String(500))

    # Описание живёт в документе (полиморфная привязка: task/project)
    document: Mapped["Document | None"] = relationship(
        "Document",
        primaryjoin="and_(foreign(Document.owner_id) == Task.id, Document.owner_type == 'task')",
        uselist=False,
        cascade="all, delete-orphan",
        lazy="selectin",
        overlaps="document",
    )

    # Тип: разовая / регулярная (регулярные — в следующих вехах)
    task_type: Mapped[str] = mapped_column(String(20), default="one_time")

    # Статус выполнения
    status: Mapped[str] = mapped_column(String(20), default="to_do")
    # Плоскость детализации стека: raw (в стеке) / approved (утверждена)
    detail_state: Mapped[str] = mapped_column(String(20), default="raw")
    done: Mapped[bool] = mapped_column(Boolean, default=False)
    # «Ментально сложная» (ТЗ 3.13): метка «не хочется делать» — трудность не по
    # объёму, а из-за волевых усилий; даёт бонус к XP при закрытии
    mentally_hard: Mapped[bool] = mapped_column(Boolean, default=False)

    parent_task_id: Mapped[int | None] = mapped_column(
        ForeignKey("tasks.id"), nullable=True, default=None
    )
    parent: Mapped["Task | None"] = relationship(
        "Task", back_populates="children", remote_side="Task.id"
    )
    children: Mapped[list["Task"]] = relationship("Task", back_populates="parent")
    project_id: Mapped[int | None] = mapped_column(
        ForeignKey("projects.id"), nullable=True, default=None
    )
    priority: Mapped[int | None] = mapped_column(Integer, nullable=True, default=None)

    # Прогнозы и бюджет (M4). estimated_minutes приходит из LLM-детализации
    # или истории завершённых задач; actual — фактическая затрата времени.
    estimated_minutes: Mapped[int | None] = mapped_column(Integer, nullable=True, default=None)
    actual_minutes: Mapped[int | None] = mapped_column(Integer, nullable=True, default=None)
    # Бюджет опционален (ТЗ 3.4): деньги задаёт пользователь, оценка затрат — рядом
    # (валюта — глобальная настройка app_settings, суммы — целые)
    budget_money: Mapped[int | None] = mapped_column(Integer, nullable=True, default=None)
    cost_estimate_money: Mapped[int | None] = mapped_column(Integer, nullable=True, default=None)

    # Дедлайны (ТЗ 3.4) — двух видов: строгий (конкретная дата) и нестрогий
    # («в течение дня/недели/месяца/года», период без конкретного дня)
    deadline_date: Mapped[date | None] = mapped_column(Date, nullable=True, default=None)
    deadline_period: Mapped[str | None] = mapped_column(String(20), nullable=True, default=None)

    # Правило повторения регулярной задачи (ТЗ 3.5): фиксированный календарь —
    # следующий экземпляр отсчитывается от даты выполнения по календарю
    recur_kind: Mapped[str | None] = mapped_column(String(20), nullable=True, default=None)
    recur_interval_days: Mapped[int | None] = mapped_column(Integer, nullable=True, default=None)
    recur_weekdays: Mapped[str | None] = mapped_column(String(20), nullable=True, default=None)
    recur_day_of_month: Mapped[int | None] = mapped_column(Integer, nullable=True, default=None)

    tags: Mapped[list["Tag"]] = relationship(
        secondary="task_tags", back_populates="tasks", lazy="selectin"
    )
    project: Mapped["Project | None"] = relationship(back_populates="tasks", lazy="joined")

    # Черновое предложение автодетализации (LLM): теги/проект/приоритет.
    # Не окончательные метаданные — применяется только при утверждении.
    ai_proposal: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True, default=None)

    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
    approved_at: Mapped[datetime | None] = mapped_column(
        DateTime(timezone=True), nullable=True, default=None
    )
    done_at: Mapped[datetime | None] = mapped_column(
        DateTime(timezone=True), nullable=True, default=None
    )
    # Метка «следующий экземпляр регулярной уже создан» (ТЗ 3.5): спавн — один
    # раз на цепочку, повторное закрытие/обновление закрытой не дублирует экземпляр
    spawned_at: Mapped[datetime | None] = mapped_column(
        DateTime(timezone=True), nullable=True, default=None
    )

    # --- Мандат ИИ-агента (ТЗ 3.20, 0.85) ---
    # Доступность задачи агенту: ставит владелец, агент — только при создании
    # задачи (иначе правило «без флага не трогать» обходилось бы «пометил — закрыл»)
    ai_eligible: Mapped[bool] = mapped_column(Boolean, default=False, server_default=false())
    # Кто создал задачу: user | agent (у агента — label токена в created_by_name).
    # Пометка нужна, чтобы список фильтровался по «создано агентом» без join'а
    created_by_kind: Mapped[str] = mapped_column(
        String(16), default="user", server_default="user"
    )
    created_by_name: Mapped[str | None] = mapped_column(String(100), nullable=True, default=None)
    # Кто закрыл задачу последним: user | agent; NULL — закрытие до 0.85, неизвестно
    done_by_kind: Mapped[str | None] = mapped_column(String(16), nullable=True, default=None)
    # Плоскость приёмки агентской работы (отдельная от статуса, как detail_state):
    # pending (ждёт приёмки) | accepted | rejected; NULL — приёмка не применяется
    accept_state: Mapped[str | None] = mapped_column(String(16), nullable=True, default=None)
    # Аренда («задачу взял агент»): держим id токена — label не уникален,
    # он только подпись для показа и журнала
    claimed_by_token_id: Mapped[int | None] = mapped_column(
        ForeignKey("mcp_tokens.id", ondelete="SET NULL"), nullable=True, default=None
    )
    claimed_by_name: Mapped[str | None] = mapped_column(String(100), nullable=True, default=None)
    claimed_at: Mapped[datetime | None] = mapped_column(
        DateTime(timezone=True), nullable=True, default=None
    )
    # Протухшая аренда считается свободной сама (лениво, без фоновых задач)
    claim_expires_at: Mapped[datetime | None] = mapped_column(
        DateTime(timezone=True), nullable=True, default=None
    )

    @property
    def claim_is_alive(self) -> bool:
        """Аренда агента ещё жива? Протухшая — как будто её нет (ТЗ 3.20)."""
        return _is_future(self.claim_expires_at)

    @property
    def claimed_by(self) -> str | None:
        """Подпись арендатора наружу — только пока аренда жива."""
        return self.claimed_by_name if self.claim_is_alive else None

    @property
    def claim_deadline(self) -> datetime | None:
        """Срок живой аренды наружу: «взято до …»; протухшая — как будто её нет."""
        return self.claim_expires_at if self.claim_is_alive else None

    @property
    def description(self) -> str:
        return self.document.body if self.document else ""

    @description.setter
    def description(self, value: str) -> None:
        if self.document is None:
            self.document = Document(
                owner_type="task", body=value, user_id=getattr(self, "user_id", None)
            )
        else:
            self.document.body = value
            self.document.updated_at = utcnow()

    @property
    def document_id(self) -> int | None:
        return self.document.id if self.document else None


class Project(Base):
    __tablename__ = "projects"
    __table_args__ = (Index("uq_projects_user_name", "user_id", "name", unique=True),)

    id: Mapped[int] = mapped_column(Integer, primary_key=True)
    user_id: Mapped[str | None] = mapped_column(
        ForeignKey("users.id"), nullable=True, default=None, index=True
    )
    name: Mapped[str] = mapped_column(String(200))

    # Заметка — тоже документ (markdown: ссылки, контекст, картинки)
    document: Mapped["Document | None"] = relationship(
        "Document",
        primaryjoin=(
            "and_(foreign(Document.owner_id) == Project.id, Document.owner_type == 'project')"
        ),
        uselist=False,
        cascade="all, delete-orphan",
        lazy="selectin",
        overlaps="document",
    )

    @property
    def note(self) -> str:
        return self.document.body if self.document else ""

    @note.setter
    def note(self, value: str) -> None:
        if self.document is None:
            self.document = Document(
                owner_type="project", body=value, user_id=getattr(self, "user_id", None)
            )
        else:
            self.document.body = value
            self.document.updated_at = utcnow()

    @property
    def document_id(self) -> int | None:
        return self.document.id if self.document else None

    # Статус актуальности проекта («Закрыт» теперь выражается архивом)
    relevance_status: Mapped[str] = mapped_column(String(20), default="active")
    # Архив (ТЗ 3.8.1): проект уезжает в историю со всеми задачами
    is_archived: Mapped[bool] = mapped_column(Boolean, default=False)
    # Булавка проекта (0.66): закреплённые держатся отдельной секцией сверху списка
    pinned: Mapped[bool] = mapped_column(Boolean, default=False)
    priority: Mapped[int | None] = mapped_column(Integer, nullable=True, default=None)
    # Краткая суммаризация заметки для контекста LLM (генерится при правке
    # заметки; null — суммаризации ещё нет, контекст берётся обрезкой заметки)
    summary: Mapped[str | None] = mapped_column(Text, nullable=True, default=None)
    # Цвет-метка проекта (0.63): кружок в списках и метка у задач, HEX #rrggbb
    color: Mapped[str | None] = mapped_column(String(7), nullable=True, default=None)
    # Сайт проекта (0.63): быстрый переход и favicon (тянет бэкенд-прокси, ТЗ 3.11)
    site_url: Mapped[str | None] = mapped_column(String(2048), nullable=True, default=None)
    # Репозиторий проекта (0.67): README оттуда показывается вторым описанием —
    # карточкой рядом с заметкой (тянет бэкенд-прокси, ТЗ 3.8)
    repository_url: Mapped[str | None] = mapped_column(String(2048), nullable=True, default=None)

    tasks: Mapped[list[Task]] = relationship(back_populates="project")

    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)


class Tag(Base):
    __tablename__ = "tags"
    __table_args__ = (Index("uq_tags_user_name", "user_id", "name", unique=True),)

    id: Mapped[int] = mapped_column(Integer, primary_key=True)
    user_id: Mapped[str | None] = mapped_column(
        ForeignKey("users.id"), nullable=True, default=None, index=True
    )
    name: Mapped[str] = mapped_column(String(100))

    tasks: Mapped[list[Task]] = relationship(
        secondary="task_tags", back_populates="tags", lazy="selectin"
    )


class TaskTag(Base):
    __tablename__ = "task_tags"

    task_id: Mapped[int] = mapped_column(
        ForeignKey("tasks.id", ondelete="CASCADE"), primary_key=True
    )
    tag_id: Mapped[int] = mapped_column(ForeignKey("tags.id", ondelete="CASCADE"), primary_key=True)


class Document(Base):
    """Markdown-текст с вложениями, привязанный к владельцу (задача/проект).

    Описания задач и заметки проектов — одна сущность: текст и его картинки
    живут вместе, вложения ссылаются на документ (см. docs/TZ.md, модель данных).
    """

    __tablename__ = "documents"
    __table_args__ = (UniqueConstraint("owner_type", "owner_id"),)

    id: Mapped[int] = mapped_column(Integer, primary_key=True)
    # Денормализованный владелец (task/project): авторизация вложений одним
    # запросом без полиморфного join'а
    user_id: Mapped[str | None] = mapped_column(
        ForeignKey("users.id"), nullable=True, default=None, index=True
    )
    owner_type: Mapped[str] = mapped_column(String(20))  # 'task' | 'project'
    owner_id: Mapped[int] = mapped_column(Integer)
    body: Mapped[str] = mapped_column(Text, default="")
    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
    updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)

    attachments: Mapped[list["Attachment"]] = relationship(
        back_populates="document", cascade="all, delete-orphan"
    )


class Attachment(Base):
    __tablename__ = "attachments"

    id: Mapped[int] = mapped_column(Integer, primary_key=True)
    document_id: Mapped[int] = mapped_column(ForeignKey("documents.id", ondelete="CASCADE"))
    filename: Mapped[str] = mapped_column(String(255))  # имя на диске (uuid)
    original_name: Mapped[str] = mapped_column(String(255))  # имя при загрузке
    mime: Mapped[str] = mapped_column(String(100))
    size: Mapped[int] = mapped_column(Integer)
    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)

    document: Mapped[Document] = relationship(back_populates="attachments")


class AppSettingGlobal(Base):
    """Глобальные настройки прежней однопользовательской эпохи.

    Больше не читается приложением — осталась только источником claim: при
    первом логине копируется в per-user app_settings (ТЗ 1.2, миграция
    mcp_users_multitenancy). Можно дропнуть отдельной миграцией позже.
    """

    __tablename__ = "app_settings_global"

    key: Mapped[str] = mapped_column(String(100), primary_key=True)
    value: Mapped[str] = mapped_column(String(200), nullable=False)


class AppSetting(Base):
    """Настройки пользователя: валюта бюджетов, язык интерфейса.

    Язык по умолчанию берётся из SSO-локали — настройка переопределяет.
    """

    __tablename__ = "app_settings"
    __table_args__ = (PrimaryKeyConstraint("user_id", "key"),)

    user_id: Mapped[str] = mapped_column(String(64), primary_key=True)
    key: Mapped[str] = mapped_column(String(100), primary_key=True)
    value: Mapped[str] = mapped_column(String(200), nullable=False)


class XpEvent(Base):
    """Событие начисления XP за закрытую задачу (геймификация, ТЗ 3.13).

    Начисляется один раз при первом закрытии задачи; уникальность по task_id
    делает начисление идемпотентным (повторное закрытие ничего не добавляет).
    Задача может быть удалена — заработанный XP остаётся (only-positive).
    """

    __tablename__ = "xp_events"
    __table_args__ = (UniqueConstraint("task_id"),)

    id: Mapped[int] = mapped_column(Integer, primary_key=True)
    user_id: Mapped[str | None] = mapped_column(
        ForeignKey("users.id"), nullable=True, default=None, index=True
    )
    task_id: Mapped[int | None] = mapped_column(
        ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True, default=None
    )
    # Событие награды: закрытие задачи или создание (задача/проект).
    # Растения в саду растут только из событий kind='task'.
    kind: Mapped[str] = mapped_column(String(20), default="task", server_default="task")
    amount: Mapped[int] = mapped_column(Integer)
    # Редкость растения в саду — решается в момент закрытия по весу задачи
    rarity: Mapped[str] = mapped_column(String(16), default="common")
    # Закрыто из режима «3 вариантов» (ТЗ 3.9) — для счётчика «выбрал и сделал»
    via_options: Mapped[bool] = mapped_column(Boolean, default=False)
    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)


class CoinEvent(Base):
    """Событие монет (геймификация, ТЗ 3.13): журнал и заработка, и трат.

    Монеты — валюта сада, отдельная от XP: опыт только растёт, монеты тратятся.
    Баланс = сумма amount по всем событиям. Начисление за задачу — один раз
    (вместе с XpEvent), бонус за уровень — один раз на уровень (уникальность
    в сервисе), траты — отрицательные amount (source='shop').
    """

    __tablename__ = "coin_events"

    id: Mapped[int] = mapped_column(Integer, primary_key=True)
    user_id: Mapped[str | None] = mapped_column(
        ForeignKey("users.id"), nullable=True, default=None, index=True
    )
    task_id: Mapped[int | None] = mapped_column(
        ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True, default=None
    )
    # Бонус за уровень (source='level') — какой уровень достигнут
    level: Mapped[int | None] = mapped_column(Integer, nullable=True, default=None)
    source: Mapped[str] = mapped_column(String(16), default="task")  # task | level | shop
    amount: Mapped[int] = mapped_column(Integer)  # + заработано / − потрачено
    # Ключ покупки (декорация или расширение карты) для трат
    item_key: Mapped[str | None] = mapped_column(String(50), nullable=True, default=None)
    # День дейли (source='daily'), UTC: уникальность (source, day) против гонки
    # двух параллельных POST /api/xp/daily — оба прошли check-then-insert
    day: Mapped[date | None] = mapped_column(Date, nullable=True, default=None)
    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)


class GardenItem(Base):
    """Элемент сцены сада: растение (закрытая задача) или декорация.

    Растение привязано к XpEvent (не к задаче — растение переживает удаление
    задачи); вид зафиксирован в item_key при рождении. Позиция — ячейки
    мелкой сетки сада (см. services/garden.py).
    """

    __tablename__ = "garden_items"
    __table_args__ = (UniqueConstraint("kind", "ref_id"),)

    id: Mapped[int] = mapped_column(Integer, primary_key=True)
    user_id: Mapped[str | None] = mapped_column(
        ForeignKey("users.id"), nullable=True, default=None, index=True
    )
    kind: Mapped[str] = mapped_column(String(16))  # plant | decoration
    ref_id: Mapped[int | None] = mapped_column(
        ForeignKey("xp_events.id", ondelete="CASCADE"), nullable=True, default=None
    )
    # Для растения — ключ вида (species), для декорации — ключ каталога
    item_key: Mapped[str | None] = mapped_column(String(50), nullable=True, default=None)
    # Позиция в ячейках сетки; NULL — декорация в инвентаре (не размещена)
    x: Mapped[int | None] = mapped_column(Integer, nullable=True, default=None)
    y: Mapped[int | None] = mapped_column(Integer, nullable=True, default=None)
    # Стадия роста растения: 0 росток, 1 куст, 2 цветение
    stage: Mapped[int] = mapped_column(Integer, default=0)
    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)


class TaskEvent(Base):
    """Журнал действий по задачам (ТЗ 3.20): кто, что и когда.

    Пишется на каждое действие — из UI, REST и MCP-инструментов; читается
    страницей «Журнал». Записи переживают задачу: при удалении задачи task_id
    обнуляется (ON DELETE SET NULL), а заголовок остаётся снапшотом — журнал
    читается как история, а не как список живых задач.
    """

    __tablename__ = "task_events"
    # Все запросы журнала идут от user_id с фильтром (и сортировкой по времени)
    __table_args__ = (
        Index("ix_task_events_user_created", "user_id", "created_at"),
        Index("ix_task_events_user_kind", "user_id", "kind"),
        Index("ix_task_events_user_task", "user_id", "task_id"),
        Index("ix_task_events_user_actor", "user_id", "actor_kind"),
    )

    id: Mapped[int] = mapped_column(Integer, primary_key=True)
    user_id: Mapped[str | None] = mapped_column(
        ForeignKey("users.id"), nullable=True, default=None, index=True
    )
    task_id: Mapped[int | None] = mapped_column(
        ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True, default=None
    )
    # Снапшот заголовка на момент действия: после удаления задачи читать нечего
    task_title: Mapped[str] = mapped_column(String(500), default="")
    project_id: Mapped[int | None] = mapped_column(
        ForeignKey("projects.id", ondelete="SET NULL"), nullable=True, default=None
    )
    # Вид события (см. services/tasklog.py): created | eligible_changed | claimed |
    # released | completed | accepted | rejected | status_changed | deleted
    kind: Mapped[str] = mapped_column(String(24))
    # Кто: user (владелец либо действие, объявленное от его имени) | agent
    actor_kind: Mapped[str] = mapped_column(String(16), default="user", server_default="user")
    # Имя владельца или label MCP-токена — подпись, а не ссылка (label не уникален)
    actor_name: Mapped[str] = mapped_column(String(100), default="")
    # Канал: ui (сессия) | api (Bearer на /api/*) | mcp (инструмент)
    via: Mapped[str] = mapped_column(String(8), default="ui", server_default="ui")
    # Что сделано (при закрытии — обязателен агенту) или причина возврата в работу
    comment: Mapped[str | None] = mapped_column(Text, nullable=True, default=None)
    created_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True), default=utcnow, server_default=func.now()
    )