← Notifications

Модель данных

notifications · workzone

Пять таблиц в core: каналы, маршруты, личные настройки, журнал событий и доставка. Один диспетчер пишет событие, разводит его по двум моделям адресации и журналирует доставку — механика в диспетчере, витрины (ленты) у Web App и Admin Panel.

1 Таблицы Каналы · маршруты · личные настройки · лента · доставка.
notification_channels куда доставляем
seed: in_app · email
id BigInteger PK
kind Text NOT NULLCHECK тип канала · → Slack — пресет
preset Text NULLCHECK формат payload вебхука · NULL у встроенных
name Text NOT NULL подпись пилюли канала в матрице
url_enc Text NULL endpoint вебхука · write-only, AES-256-GCM · NULL у встроенных
secret_enc Text NULL HMAC-secret подписи тела (generic) · write-only, AES-256-GCM · опционален, NULL у встроенных и Slack
is_builtin Boolean NOT NULLDEFAULT true у in_app / email · неудаляемы
enabled Boolean NOT NULLDEFAULTCHECK false = канал на паузе: маршруты целы, доставка стоит · in_app — CHECK всегда true
last_test_ok Boolean NULL результат кнопки «Test» · NULL = не проверяли
last_test_at DateTime(tz) NULL
meta JSONB NOT NULLDEFAULT пресет-специфика (канал Slack, заголовки)
created_at DateTime(tz) DEFAULT now()
updated_at DateTime(tz) DEFAULT now() + trigger
notification_routes матрица тип × канал · defaults
FK → notification_channels UNIQUE(event_type, channel_id)
id BigInteger PK
event_type Text NOT NULLUNIQUE*CHECK категория · → Матрица
channel_id BigInteger FK→channelsIDX столбец матрицы
enabled Boolean NOT NULLDEFAULTCHECK пилюля канала в матрице · ортогонально паузе канала · → Замок
locked Boolean NOT NULLDEFAULT in-app × security · sync — true: этот маршрут выключить нельзя
created_at DateTime(tz) DEFAULT now()
updated_at DateTime(tz) DEFAULT now() + trigger
notification_prefs личное сужение · профиль
FK → users UNIQUE(user_id, event_type)
id BigInteger PK
user_id BigInteger FK→usersIDX любой пользователь · users
event_type Text NOT NULLUNIQUE*CHECK категория
in_app_enabled Boolean NOT NULLDEFAULT личный тумблер in-app по типу · свободный opt-out
email_enabled Boolean NOT NULL личный тумблер email по типу · значение при INSERT пишет код из каталога типа, не колоночный DEFAULT (личные типы — opt-in false)
created_at DateTime(tz) DEFAULT now()
updated_at DateTime(tz) DEFAULT now() + trigger · строки нет = дефолт каталога
notifications событие · лента in-app · аудит
журнал · дедуп серии в окне
id BigInteger PK
event_type Text NOT NULLCHECK категория · каталог
severity Text NOT NULLCHECK чип серьёзности · ортогонален типу
target_user_id BigInteger FK→usersNULLIDX адресат targeted-события (agent · account) · NULL = broadcast по роли · → Две модели
title Text NOT NULL заголовок · i18n-ключ (agent.run_failed), не готовая строка
body Text NULL тело · i18n-ключ · NULL = только заголовок
title_params JSONB NOT NULLDEFAULT параметры подстановки для title / body ({agent:"Sales watch"}) · рендер на языке читателя
source Text NULL модуль-источник (harvester · auth · agent_engine · knowledge_store · admin)
source_ref Text NULL диплинк на сущность (agent/7 · run/42 · source/17)
meta JSONB NOT NULLDEFAULT payload действия (Discovery: контейнер для «в scope»)
dedup_key Text NULLIDX ключ схлопывания серии · NULL = не дедупится · → Тротлинг
dedup_count Integer NOT NULLDEFAULT счётчик серии «×N» · растёт при повторе в окне
last_seen_at DateTime(tz) NULL момент последнего события серии · сравнивается с окном дедупа
created_at DateTime(tz) DEFAULT now() · первое событие серии
updated_at DateTime(tz) DEFAULT now() + trigger · мутирует при инкременте дедупа
notification_deliveries доставка по каналам · read-state
FK → notifications · channels · users UNIQUE(notification_id, channel_id, user_id) UNIQUE(notification_id, channel_id) WHERE user_id IS NULL
id BigInteger PK
notification_id BigInteger FK→notificationsUNIQUE*IDX какое событие · ключ идемпотентности доставки
channel_id BigInteger FK→channelsNULLIDX in_app · email · webhook · webhook-канал удалён → NULL, строка доставки цела (аудит)
user_id BigInteger FK→usersNULLIDX личный адресат (in_app / email) · NULL у webhook
state Text NOT NULLCHECK статус доставки · read только у in_app
error Text NULL причина failed
sent_at DateTime(tz) NULL момент отправки
read_at DateTime(tz) NULL in_app: когда прочитано · unread = NULL
created_at DateTime(tz) DEFAULT now()
updated_at DateTime(tz) DEFAULT now() + trigger · state мутирует
Две модели адресации — их разводит одна колонка target_user_id → Диспетчер
broadcast target_user_id = NULL
Адресатсрез по роли: users с owner/admin и status=active, вычисляется запросом — повысили до Admin → в адресатах, сняли роль → выбыл
Типыsync · security · budget · system · discovery
Webhookда — у доставки user_id = NULL
targeted target_user_id = N
Адресатодин пользователь, любой — включая member
Типыagent (прогон, governance) · account (роль, временный пароль)
Webhookнет — личные типы по вебхукам не идут
Колонка аддитивна: старые типы пишут NULL и идут прежним путём, ветка targeted — одно условие в диспетчере; deliveries.user_id уже per-user, схему доставки менять не пришлось. Отдельной таблицы получателей нет, личное сужение хранит notification_prefs (per-user × тип, флаги in_app и email). Внешнего адресата-человека не заводим принципиально — его роль играет webhook-канал, а не строка-получатель вне системы ролей (личные настройки).
Slack — пресет вебхука, не отдельная сущность → Экран каналов
Один notification_channels держит все каналы — сценарии «Slack» и «любой инструмент» сходятся в одну таблицу, матрица notification_routes ссылается на каналы единообразно по channel_id.
  • in_app · email — is_builtin-строки из миграции: неудаляемы, in_app не выключить
  • webhook — обычная строка с preset (slack · generic), выбирающим формат payload; выделенной колонки у Slack нет
  • webhook — канал только broadcast-типов: у него user_id = NULL, личные типы по нему не расходятся
  • это исходящие оповещения в канал — разговорный Slack-бот отдельная сущность: входящая и личная
Дефолт канала — в каталоге типа; email юзерских типов opt-in → Каталог
доставка по каналу = routes.enabled ∧ (prefs.<канал>_enabled либо дефолт каталога) — одна формула для broadcast и targeted
платформенные
sync · security · budget · system · discovery → in-app + email
agent · account
только in-app; email opt-in — юзер включает сам в профиле
Строки notification_prefs нет — берётся дефолт каталога типа, не глобальный «оба включены». Так member по умолчанию видит колокольчик, но писем не получает, пока не захочет.
Что нельзя выключить централизованно
Гарантирован один маршрут — in-app для security и sync: критичный тип всегда виден хотя бы в ленте, один администратор не оставит организацию без сигнала.
  • Замок: маршруты in_app × security и in_app × sync засеяны locked=true; CHECK (enabled OR NOT locked) не даёт выключить
  • Уровень организационный, только in-app: email и webhook этих типов, как и все каналы прочих типов — свободны
  • Личная отписка всегда доступна (notification_prefs); agent · account замком не держатся
Событие — журнал, доставка — состояние
  • notifications — журнал, ~append-only; единственная мутация: дедуп серии в окне (тот же dedup_keydedup_count++, last_seen_at), за окном иммутабельно
  • Текст — i18n-ключи title · body + title_params, рендер на языке читателя, не заморожённая строка
  • notification_deliveries — состояние (queued → sent → read); лента in-app = notifications ⋈ свои deliveries, unread = read_at IS NULL
  • Удаление: событие → каскад deliveries; юзер → свои личные строки (broadcast-факт цел); webhook-канал → channel_id = NULL (аудит жив)
Диспетчер и источники событий — в механике.
2 Миграция Создаёт таблицы и засевает каналы и маршруты.
alembic/versions/NNN_core_notifications.py
Создаёт пять таблиц в порядке зависимостей: notification_channelsnotification_routes · notification_prefs · notificationsnotification_deliveries.
  • INSERT двух встроенных каналов (is_builtin: in_app, email)
  • Засев полной матрицы routes — строка на каждую клетку тип × встроенный канал (enabled=true); webhook у targeted-типов agent · account не засевается
  • Клетки in_app × security и in_app × sync — locked=true (что нельзя выключить)
  • downgrade() сносит таблицы в обратном порядке