"""Справочники: источники, API-ключи, типы уведомлений, цели каналов.
Договор приёма — docs/05-ingestion-api.md. Секреты каналов (токен Telegram-бота,
SMTP-креды) — в .env; в channel_targets.config только идентификаторы целей
(chat_id, адрес, s2s-точка) — они не секреты, а конфигурация.
"""
from datetime import datetime
from sqlalchemy import (
CheckConstraint,
DateTime,
ForeignKey,
Integer,
String,
Text,
UniqueConstraint,
func,
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
# Каналы доставки — те же значения в channel_targets и routing_rule_actions.
# internal_log не имеет внешних кредов (это БД); user — личный лог пользователя
# и push — web-push подписки пользователя (оба из payload.user_id, цель не задаётся).
CHANNELS = ("telegram", "email", "s2s", "internal_log", "user", "push")
PRIORITIES = ("low", "normal", "high", "critical")
# Статусы конверта события (events.status):
# queued — принято на приёме, ждёт воркера
# processing — воркер маршрутизирует/раскидывает доставки
# done — маршрутизация завершена; живые статусы — в deliveries
# failed — событие не маршрутизировано (после финальных ретраев правил)
EVENT_STATUSES = ("queued", "processing", "done", "failed")
class Source(Base):
"""Источник событий; регистрируется в админке, к нему выдаётся API-ключ."""
__tablename__ = "sources"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(64), unique=True) # slug: "monitoring"
label: Mapped[str | None] = mapped_column(String(120)) # человекочитаемое
# Описание «что это за сервис» для человека и ИИ-агента (админка Synapse):
# не гадать по имени источника. В конверт события и s2s-вебхук не входит.
description: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
# Архив вместо удаления (MCP): DELETE ставит метку, restore снимает.
# Живые пути фильтруют deleted_at.is_(None); история — нет.
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
class ApiKey(Base):
"""API-ключ источника (`Authorization: Bearer syn_...`).
Хранится только sha256-хэш (token_hash) и последние 4 символа (token_hint)
для поиска в UI. Ключ — удостоверение источника: событие обязано нести
source, совпадающий с ключом, иначе 403. Ротация ключа — новая запись
той же строкой sources, старая — revoked_at.
"""
__tablename__ = "api_keys"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
source_id: Mapped[int] = mapped_column(
ForeignKey("sources.id", ondelete="CASCADE"), index=True
)
name: Mapped[str] = mapped_column(String(120)) # человекочитаемое назначение
token_hash: Mapped[str] = mapped_column(String(64), unique=True)
token_hint: Mapped[str] = mapped_column(String(8))
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
class NotificationType(Base):
"""Тип уведомления = тройка (source, subject, action) из контракта."""
__tablename__ = "notification_types"
__table_args__ = (
UniqueConstraint("source_id", "subject", "action"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
source_id: Mapped[int] = mapped_column(
ForeignKey("sources.id", ondelete="CASCADE"), index=True
)
subject: Mapped[str] = mapped_column(String(64))
action: Mapped[str] = mapped_column(String(64))
# Опциональная JSON Schema payload'а; проверяется воркером, не на приёме.
payload_schema: Mapped[dict | None] = mapped_column(JSONB)
description: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
class ChannelTarget(Base):
"""Цель канала: куда именно доставлять (TG-чат, адрес, s2s-точка).
config — JSON с идентификатором цели; формат зависит от channel.
Примеры: telegram → {"chat_id": "@infra-squad"}, email → {"to": "ops@…"},
s2s → {"endpoint": "http://navi-rei:…/hooks", "token_ref": "navi-rei"}.
"""
__tablename__ = "channel_targets"
__table_args__ = (CheckConstraint(f"channel IN {CHANNELS}", name="ck_channel"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
channel: Mapped[str] = mapped_column(String(32))
name: Mapped[str] = mapped_column(String(120))
# Описание «что это за клиент-сервис» — для человека и ИИ-агента в админке:
# не гадать по названию (например, «Navi rei — домашний ассистент на swarm»).
description: Mapped[str | None] = mapped_column(Text)
config: Mapped[dict] = mapped_column(JSONB)
enabled: Mapped[bool] = mapped_column(default=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))