Пять таблиц в 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-канал,
а не строка-получатель вне системы ролей
(
личные настройки).
Один 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_key → dedup_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_channels →
notification_routes · notification_prefs ·
notifications → notification_deliveries.
INSERT двух встроенных каналов (is_builtin: in_app, email)
- Засев полной матрицы
routes — строка на каждую клетку тип × встроенный канал (enabled=true); webhook у targeted-типов agent · account не засевается
- Клетки in_app × security и in_app × sync —
locked=true (что нельзя выключить)
downgrade() сносит таблицы в обратном порядке