diff --git a/README.md b/README.md index 13f0562..25559b2 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ - FastAPI-приложение: `/api/healthz` (liveness), `/api/readyz` (зависимости), `/api/docs` (Swagger) - Гейт админки по ролям gnexus-auth: `app/auth/deps.py` (`get_current_user` → `require_admin`, 401/403) - Celery-воркер с каркасной задачей `synapse.ping` -- Alembic (миграции пока пустые — схема БД в задаче #34) +- Alembic-миграции: схема v1 (#34) — `docs/04-database.md` - Vue 3 SPA: вход (dev-заглушка), дашборд, экран «доступ запрещён» для роли ниже admin - docker-compose: api + worker + postgres + redis, миграции применяются при старте @@ -72,10 +72,12 @@ main.py FastAPI: роуты API + статики SPA config.py настройки из .env database.py SQLAlchemy engine/session - auth/deps.py SSO-валидация + гейт ролей admin/superadmin + models/ схема БД (#34): источники, ключи, типы, правила, события, доставки + auth/ SSO-валидация + гейт ролей admin/superadmin api/routes.py healthz, readyz, admin/me worker/ Celery: celery_app, tasks alembic/ миграции (env.py читает DATABASE_URL из .env) +docs/ docs/04 — схема БД, docs/05 — контракт Ingestion API frontend/ Vue 3 SPA админки (сборка кладётся в spa_static/ образа) docker/entrypoint.sh режимы api (миграции+uvicorn) / worker (celery) docker-compose.yml api, worker, postgres, redis diff --git a/alembic/env.py b/alembic/env.py index 6712770..3a87f58 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -7,7 +7,7 @@ from app.database import engine # Импорт моделей здесь обязателен: Base.metadata должен знать все таблицы. -# from app import models # включить в задаче #34 (схема БД) — пока моделей нет +import app.models # noqa: F401 config = context.config target_metadata = __import__("app").database.Base.metadata diff --git a/alembic/versions/20261003_0001_initial_schema.py b/alembic/versions/20261003_0001_initial_schema.py new file mode 100644 index 0000000..1694ad6 --- /dev/null +++ b/alembic/versions/20261003_0001_initial_schema.py @@ -0,0 +1,149 @@ +"""init: sources, api_keys, notification_types, channel_targets, routing_rules (+actions), events, deliveries + +Revision ID: 8737d8f652f9 +Revises: +Create Date: 2026-10-02 23:58:50.896349 +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision = '8737d8f652f9' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('channel_targets', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('channel', sa.String(length=32), nullable=False), + sa.Column('name', sa.String(length=120), nullable=False), + sa.Column('config', postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.Column('enabled', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.CheckConstraint("channel IN ('telegram', 'email', 's2s', 'internal_log')", name='ck_channel'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('routing_rules', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=120), nullable=False), + sa.Column('enabled', sa.Boolean(), nullable=False), + sa.Column('weight', sa.Integer(), nullable=False), + sa.Column('conditions', postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.Column('template', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('sources', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=64), nullable=False), + sa.Column('label', sa.String(length=120), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name') + ) + op.create_table('api_keys', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('source_id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=120), nullable=False), + sa.Column('token_hash', sa.String(length=64), nullable=False), + sa.Column('token_hint', sa.String(length=8), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('last_used_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['source_id'], ['sources.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('token_hash') + ) + op.create_index(op.f('ix_api_keys_source_id'), 'api_keys', ['source_id'], unique=False) + op.create_table('events', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('source_id', sa.Integer(), nullable=False), + sa.Column('subject', sa.String(length=64), nullable=False), + sa.Column('action', sa.String(length=64), nullable=False), + sa.Column('priority', sa.String(length=16), nullable=False), + sa.Column('payload', postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.Column('dedup_key', sa.String(length=255), nullable=True), + sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('scheduled_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('status', sa.String(length=16), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), + sa.CheckConstraint("priority IN ('low','normal','high','critical')", name='ck_priority'), + sa.CheckConstraint("status IN ('queued','processing','done','failed')", name='ck_event_status'), + sa.ForeignKeyConstraint(['source_id'], ['sources.id'], ondelete='RESTRICT'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index('ix_events_dedup', 'events', ['dedup_key', 'created_at'], unique=False) + op.create_index(op.f('ix_events_source_id'), 'events', ['source_id'], unique=False) + op.create_table('notification_types', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('source_id', sa.Integer(), nullable=False), + sa.Column('subject', sa.String(length=64), nullable=False), + sa.Column('action', sa.String(length=64), nullable=False), + sa.Column('payload_schema', postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['source_id'], ['sources.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('source_id', 'subject', 'action') + ) + op.create_index(op.f('ix_notification_types_source_id'), 'notification_types', ['source_id'], unique=False) + op.create_table('routing_rule_actions', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('rule_id', sa.Integer(), nullable=False), + sa.Column('channel', sa.String(length=32), nullable=False), + sa.Column('target_id', sa.Integer(), nullable=True), + sa.Column('template', sa.Text(), nullable=True), + sa.CheckConstraint("channel IN ('telegram', 'email', 's2s', 'internal_log')", name='ck_channel'), + sa.ForeignKeyConstraint(['rule_id'], ['routing_rules.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['target_id'], ['channel_targets.id'], ondelete='SET NULL'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_routing_rule_actions_rule_id'), 'routing_rule_actions', ['rule_id'], unique=False) + op.create_table('deliveries', + sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column('event_id', sa.Uuid(), nullable=False), + sa.Column('rule_id', sa.Integer(), nullable=True), + sa.Column('channel', sa.String(length=32), nullable=False), + sa.Column('channel_target_id', sa.Integer(), nullable=True), + sa.Column('status', sa.String(length=16), nullable=False), + sa.Column('attempts', sa.Integer(), nullable=False), + sa.Column('last_error', sa.Text(), nullable=True), + sa.Column('next_retry_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('rendered_message', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('delivered_at', sa.DateTime(timezone=True), nullable=True), + sa.CheckConstraint("channel IN ('telegram','email','s2s','internal_log')", name='ck_channel'), + sa.CheckConstraint("status IN ('pending','delivered','failed')", name='ck_delivery_status'), + sa.ForeignKeyConstraint(['channel_target_id'], ['channel_targets.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['event_id'], ['events.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['rule_id'], ['routing_rules.id'], ondelete='SET NULL'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_deliveries_event_id'), 'deliveries', ['event_id'], unique=False) + op.create_index('ix_deliveries_retry', 'deliveries', ['status', 'next_retry_at'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index('ix_deliveries_retry', table_name='deliveries') + op.drop_index(op.f('ix_deliveries_event_id'), table_name='deliveries') + op.drop_table('deliveries') + op.drop_index(op.f('ix_routing_rule_actions_rule_id'), table_name='routing_rule_actions') + op.drop_table('routing_rule_actions') + op.drop_index(op.f('ix_notification_types_source_id'), table_name='notification_types') + op.drop_table('notification_types') + op.drop_index(op.f('ix_events_source_id'), table_name='events') + op.drop_index('ix_events_dedup', table_name='events') + op.drop_table('events') + op.drop_index(op.f('ix_api_keys_source_id'), table_name='api_keys') + op.drop_table('api_keys') + op.drop_table('sources') + op.drop_table('routing_rules') + op.drop_table('channel_targets') + # ### end Alembic commands ### \ No newline at end of file diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..5e74c6f --- /dev/null +++ b/app/models/__init__.py @@ -0,0 +1,16 @@ +"""SQLAlchemy-модели Synapse. Все модули импортируются для Base.metadata.""" + +from app.models.dicts import ApiKey, ChannelTarget, NotificationType, Source +from app.models.events import Delivery, Event +from app.models.rules import RoutingRule, RoutingRuleAction + +__all__ = [ + "Source", + "ApiKey", + "NotificationType", + "ChannelTarget", + "RoutingRule", + "RoutingRuleAction", + "Event", + "Delivery", +] diff --git a/app/models/dicts.py b/app/models/dicts.py new file mode 100644 index 0000000..4fa39c0 --- /dev/null +++ b/app/models/dicts.py @@ -0,0 +1,117 @@ +"""Справочники: источники, 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 не имеет внешних кредов (это БД); push (web-push) пока не включаем. +CHANNELS = ("telegram", "email", "s2s", "internal_log") + +PRIORITIES = ("low", "normal", "high", "critical") + +# Статусы конверта события (events.status): +# queued — принято на приёме, ждёт воркера +# processing — воркер маршрутизирует/раскидывает доставки +# done — все доставки в терминальном состоянии +# 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)) # человекочитаемое + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + + +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() + ) + + +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)) + 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() + ) \ No newline at end of file diff --git a/app/models/events.py b/app/models/events.py new file mode 100644 index 0000000..a60088a --- /dev/null +++ b/app/models/events.py @@ -0,0 +1,94 @@ +"""События и их доставки. + +Event — конверт из docs/05-ingestion-api.md; Delivery — попытка/результат +доставки в конкретную цель конкретного канала по подошедшему правилу. +""" + +import uuid +from datetime import datetime + +from sqlalchemy import ( + BigInteger, + CheckConstraint, + DateTime, + ForeignKey, + Index, + String, + Text, + func, +) +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column + +from app.database import Base + +# Статусы доставки: pending (в работе) → delivered | failed. +# Одна запись на доставку: счётчик attempts и next_retry_at, не новые строки. +DELIVERY_STATUSES = ("pending", "delivered", "failed") + + +class Event(Base): + __tablename__ = "events" + __table_args__ = ( + CheckConstraint("priority IN ('low','normal','high','critical')", name="ck_priority"), + CheckConstraint("status IN ('queued','processing','done','failed')", name="ck_event_status"), + Index("ix_events_dedup", "dedup_key", "created_at"), + ) + + id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) + source_id: Mapped[int] = mapped_column( + ForeignKey("sources.id", ondelete="RESTRICT"), index=True + ) + subject: Mapped[str] = mapped_column(String(64)) + action: Mapped[str] = mapped_column(String(64)) + priority: Mapped[str] = mapped_column(String(16), default="normal") + payload: Mapped[dict] = mapped_column(JSONB, default=dict) + + dedup_key: Mapped[str | None] = mapped_column(String(255)) + # created_at + ttl_seconds; воркер не берёт события с expires_at < now. + expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + # Резерв контракта: в MVP scheduling не реализован. + scheduled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + + status: Mapped[str] = mapped_column(String(16), default="queued") + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + updated_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), onupdate=func.now() + ) + + +class Delivery(Base): + __tablename__ = "deliveries" + __table_args__ = ( + CheckConstraint("channel IN ('telegram','email','s2s','internal_log')", name="ck_channel"), + CheckConstraint("status IN ('pending','delivered','failed')", name="ck_delivery_status"), + Index("ix_deliveries_retry", "status", "next_retry_at"), + ) + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + event_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("events.id", ondelete="CASCADE"), index=True + ) + # Какое правило породило доставку (nullable: событие, не попавшее ни в одно + # правило, тоже пишем — история «что случилось»). + rule_id: Mapped[int | None] = mapped_column( + ForeignKey("routing_rules.id", ondelete="SET NULL") + ) + channel: Mapped[str] = mapped_column(String(32)) + channel_target_id: Mapped[int | None] = mapped_column( + ForeignKey("channel_targets.id", ondelete="SET NULL") + ) + + status: Mapped[str] = mapped_column(String(16), default="pending") + attempts: Mapped[int] = mapped_column(default=0) + last_error: Mapped[str | None] = mapped_column(Text) + next_retry_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + # Текст, реально отправленный в канал (рендер шаблона) — аудит. + rendered_message: Mapped[str | None] = mapped_column(Text) + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + delivered_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) diff --git a/app/models/rules.py b/app/models/rules.py new file mode 100644 index 0000000..dc718c4 --- /dev/null +++ b/app/models/rules.py @@ -0,0 +1,70 @@ +"""Правила маршрутизации (Routing Engine, задача #30). + +Условия — JSONB (MVP-набор полей, легко расширять): + {"source": "monitoring" | null, # null = любой источник + "subjects": [], # пусто = любые subject + "actions": [], # пусто = любые action + "priority_min": "normal"} # в шкале low/normal/high/critical + +Payload-матчинг и теги в v1 контракта нет — условия набора хватает для +принятых примеров (см. docs/05-ingestion-api.md). +""" + +from datetime import datetime + +from sqlalchemy import ( + CheckConstraint, + DateTime, + ForeignKey, + Integer, + String, + Text, + func, +) +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column + +from app.database import Base +from app.models.dicts import CHANNELS + + +class RoutingRule(Base): + """Правило: условия → набор действий (каналы/цели/шаблоны). + + weight — порядок применения: чем меньше, тем раньше. В MVP применяется + только первое подошедшее правило (later: first/all решается в #30). + """ + + __tablename__ = "routing_rules" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + name: Mapped[str] = mapped_column(String(120)) + enabled: Mapped[bool] = mapped_column(default=True) + weight: Mapped[int] = mapped_column(default=0) + conditions: Mapped[dict] = mapped_column(JSONB) + # Шаблон по умолчанию для доставок правила (Jinja2/`{{ payload.x }}`); + # individual action может переопределить. + template: Mapped[str | None] = mapped_column(Text) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + updated_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), onupdate=func.now() + ) + + +class RoutingRuleAction(Base): + """Действие правила: доставить в канал channel в цель target.""" + + __tablename__ = "routing_rule_actions" + __table_args__ = (CheckConstraint(f"channel IN {CHANNELS}", name="ck_channel"),) + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + rule_id: Mapped[int] = mapped_column( + ForeignKey("routing_rules.id", ondelete="CASCADE"), index=True + ) + channel: Mapped[str] = mapped_column(String(32)) + target_id: Mapped[int | None] = mapped_column( + ForeignKey("channel_targets.id", ondelete="SET NULL") + ) + template: Mapped[str | None] = mapped_column(Text) # переопределение шаблона \ No newline at end of file diff --git a/docs/04-database.md b/docs/04-database.md new file mode 100644 index 0000000..8f34295 --- /dev/null +++ b/docs/04-database.md @@ -0,0 +1,47 @@ +# 04 — Схема БД (#34), v1 + +Реализация — `app/models/` + миграция `alembic/versions/20261003_0001_initial_schema.py` (7 таблиц). Постгрес в докере (postgres:17-alpine, том `pgdata`), Redis 7 (том `redisdata`). + +Два контура данных: **конфигурация** (человек заводит через админку, меняется редко) и **поток событий** (пишется автоматически, растёт всегда). + +## Конфигурация + +``` +sources ──< api_keys + │ + └──< notification_types (уникальность: source_id + subject + action) + │ +routing_rules ──< routing_rule_actions ──> channel_targets +``` + +| Таблица | Что хранит | Ключевые поля | +|---|---|---| +| `sources` | Источники событий (регистрирует админ) | `name` (slug, unique: `monitoring`), `label` | +| `api_keys` | Ключи `syn_…` к источникам | **`token_hash`** (sha256, unique), `token_hint` (последние 4 для UI), `revoked_at` (ротация: новый ключ, старая строка маркируется). Ключ — удостоверение; правила матчают `source`, не токен | +| `notification_types` | Реестр типов из контракта | тройка `(source_id, subject, action)` уникальна, `payload_schema` JSONB (опц., валидирует воркер) | +| `channel_targets` | Цели каналов: куда доставлять | `channel` ∈ `telegram/email/s2s/internal_log` (CHECK), `config` JSONB (chat_id, адрес, s2s-endpoint). **Креды каналов (токен бота, SMTP) — не здесь, а в .env** | +| `routing_rules` | Правила «условия → действия» | `conditions` JSONB (`source`, `subjects[]`, `actions[]`, `priority_min`), `template` (Jinja2 `{{ payload.x }}`), `weight` (порядок; в MVP применяется первое подошедшее), `enabled` | +| `routing_rule_actions` | Действия правила | `rule_id`, `channel` (CHECK), `target_id` → channel_targets (SET NULL при удалении цели), `template` — переопределение шаблона правила | + +## Поток событий + +``` +events 1 ──< deliveries +``` + +| Таблица | Что хранит | Ключевые поля | +|---|---|---| +| `events` | Конверт события (контракт docs/05) | UUID pk, `source_id` (RESTRICT — события переживают удаление источника), `subject`/`action`/`priority` (CHECK по шкале), `payload` JSONB, `dedup_key` (+индекс с `created_at` — окно дедупликации), `expires_at` (= created+ttl_seconds), `scheduled_at` (резерв), `status` ∈ `queued/processing/done/failed` | +| `deliveries` | Доставка в конкретную цель | `event_id` (CASCADE), `rule_id` (SET NULL — правило удалим, историю оставим), `channel`, `channel_target_id`, `status` ∈ `pending/delivered/failed`, `attempts`, `last_error`, `next_retry_at` (+индекс `status,next_retry_at` — скан ретраев), `rendered_message` (аудит: что реально ушло в канал) | + +## Что сознательно НЕ в таблицах + +- **Пользователи** — их делает gnexus-auth; Synapse пользователей не хранит. +- **Секреты каналов** — .env/`gnexus-creds`, не БД (`TELEGRAM_BOT_TOKEN`, SMTP и т.д.). +- **`tags` события** — убраны из v1 контракта (см. docs/05); вернутся, когда появится правило, требующее их. +- **История попыток отдельно от delivery** — одна запись с `attempts`/`next_retry_at`; поэлементный лог попыток не нужен MVP. + +## Проверено + +- `alembic upgrade head` поднимает всё из пустой базы (docker: api применяет миграции на старте). +- ORM roundtrip: Source → ApiKey → NotificationType → ChannelTarget → RoutingRule(+Action) → Event → Delivery — вставляется и читается; демо-данные удалены, схема оставлена пустой. \ No newline at end of file