Добавлены уведомления

This commit is contained in:
mi
2026-07-27 17:36:53 +03:00
parent a072005164
commit 958fba5f3e
149 changed files with 6371 additions and 110 deletions
+16
View File
@@ -33,6 +33,11 @@
| `app_settings` | `han_app` | Бизнес-настройки |
| `text_resources` | `han_app` | Тексты UI по мнемоникам |
| `popular_questions` | `han_app` | Популярные вопросы главного экрана |
| `Notification` / `GuestNotification` | `han_app` | Персональное уведомление / общая гостевая кампания |
| `NotificationType` | `han_app` | Вид уведомления как данные: контур, приоритет, CTA, кнопки и оформление |
| `NotificationCtaAction` / `NotificationButton` / `NotificationColorToken` | `han_app` | Реестры реализованных механик CTA, кнопок и семантической палитры |
| `NotificationSource` | `han_app` | Продюсер Internal Notifications API; хранит hash индивидуального токена, не секрет |
| `ClientDocument` | `han_app` | Отправленный клиентом проверенный документ; создаёт `document.client_uploaded` в `sync_queue` |
| `dialog_sessions` | `bitrix_local` | Маппинг чата Open Lines |
| `sms_template` | `sms` | Версионируемый согласованный SMS-шаблон; active-версия уникальна для `code`+`channel`+`locale` |
| `sms_setting` | `sms` | Технические runtime-настройки `sms-service`, не секреты и не OTP product settings |
@@ -54,6 +59,8 @@
| `task_id` | ID async-проверки Message Safety |
| `request_id` | Корреляция HTTP-запроса (заголовок `X-Request-ID`) |
| `sms_message_id` | UUID `sms.sms_outbound_message.id`; логическая ссылка из Keycloak challenge/event, межсхемного FK нет |
| `notification_id` | UUID v7 персонального или гостевого уведомления; генерирует приложение/seed |
| `external_id` уведомления | Бессрочный бизнес-ключ продюсера в паре с `source` |
| `provider_message_id` | `messageUuid` i-Digital Direct; хранится только в `sms-service` |
| `provider_external_id` | `externalMessageId`; в v1 равен `sms_message_id` и является корреляцией, а не доказанной идемпотентностью Direct |
@@ -154,6 +161,15 @@ Realtime-событие `message.status` передаёт актуальные `
| `sync` | `bitrix-sync` |
| `settings` | internal settings bridge на `api-backend` для Keycloak SPI |
| `sms` | `sms-service`; durable order/read API во внутренней сети |
| `notifications` | Internal Create/Cancel уведомлений на `api-backend`; токен отдельный для каждого `source` |
## Жизненный цикл уведомления
- `lifecycle_status`: `active` / `closed`; бизнес-завершение, не soft delete.
- `visibility`: `visible` / `hidden`; скрытое персональное уведомление отсутствует на главной, но остаётся в Центре, пока активно.
- `close_reason`: `user_done`, `docs_submitted`, `offer_accepted`, `paid`, `expired`, `cancelled`.
- `record_status='D'` означает только административное удаление ошибочной записи и не заменяет `lifecycle_status`.
- `cta_action` и эффекты кнопок определяются справочниками; новый вид на существующих механиках добавляется данными.
## SMS-конфигурация
+10 -3
View File
@@ -21,6 +21,8 @@ HAN Chat - приложение для мигрантов, где стартов
- Вложения чата MVP: **только изображения и PDF** — см. [`arch-04-settings-and-content.md`](arch-04-settings-and-content.md), «Разрешённые типы файлов чата».
- SMS OTP вводится поэтапно: до production rollout действует явный mock (`KEYCLOAK_OTP_MOCK_ENABLED=true`); целевой real mode — Keycloak генерирует/локально проверяет OTP и создаёт durable order в `sms-service`, а worker асинхронно вызывает i-Digital Direct. Контракт и gates — [`module-11-idgtl-sms.md`](../modules/module-11-idgtl-sms.md).
- Популярный вопрос при выборе **автоматически отправляется как сообщение**; если пользователь не авторизован — сначала согласия и OTP, затем отправка.
- Notification Center v1 использует два контура: G — общие read-only гостевые кампании, P — персональные уведомления с состоянием в App DB. Виды, CTA, кнопки и палитра задаются каталогом данных.
- Инструкция `install_app` всегда открывается во внешней новой вкладке; iframe/модалка для неё не используется.
- Перечень таблиц и миграций App DB проектирует модуль `database` (и владельцы схем других сервисов); arch фиксирует только **разделение схем** PostgreSQL и контракты между сервисами.
## Пользовательские сценарии
@@ -44,6 +46,7 @@ HAN Chat - приложение для мигрантов, где стартов
- Keycloak: identity provider, OTP-only авторизация по номеру телефона.
- SMS Service: internal durable order API, шаблоны и бессрочный журнал SMS; отдельный worker вызывает i-Digital Direct, callback обновляет только журнал.
- api-backend: Python-приложение с REST API, realtime-доставкой сообщений и бизнес-логикой.
- Notification producers: сервисы приватной сети, создающие/отменяющие персональные уведомления через Internal API с отдельным Bearer token на `source`; `producer_test` используется только для smoke API.
- Nginx Reverse Proxy: единая публичная точка входа, HTTPS termination и маршрутизация на Keycloak/API/frontend web/Bitrix24.
- Message Safety Service: отдельный сервис проверки входящих сообщений; вызов из API → `200 allow` | `403 deny` | `203 pending` + `task_id` (при `203` api-backend синхронно поллит task до финального вердикта, без очереди анализа на api-backend).
- Bitrix24 Local App: локальное приложение, custom connector `han_mobile_app` для Bitrix24 Open Lines: чат, OAuth, webhook-события, маппинг `dialog_id``bitrix_chat_id`.
@@ -186,6 +189,9 @@ Frontend не должен:
- circuit breaker + timeout budget на вызовы `message-safety` и `bitrix-local-app` (I2);
- auth-aware rate limits для сообщений, пользовательских и сервисных операций;
- аудит пользовательских действий;
- публичный каталог/гостевые кампании, JWT API Notification Center и Internal Create/Cancel; дедупликацию по бессрочной паре `(source, external_id)`;
- применение каталога уведомлений без ветвления по `notification_type`, пользовательские действия, документы и события `notification.created|updated|closed`;
- expire job и очистку upload drafts. При скрытии TTL задаёт `date_expired` только если оно отсутствует; существующая дата не меняется;
- единые ошибки и валидацию входных данных.
### Bitrix24 Local App
@@ -458,11 +464,11 @@ api-backend не решает, sync или async нужна проверка в
9. Frontend отображает сообщение оператора в чате.
10. При получении от `bitrix-local-app` доменного события `dialog.closed` (Bitrix24 `ONIMCONNECTORDIALOGFINISH`) API переводит `Dialog.status` в `closed`.
## Документы компании (post-MVP)
## Документы компании
Доставка документов из Bitrix24 в приложение **не входит в MVP** — см. [`!Backlog.md`](../../HAN_chat/!Backlog.md), п. 9.
Notification Center v1 регистрирует переданные продюсером объекты `han-chat-documents` в реестре `documents` и связывает их с уведомлением. Это первый действующий канал наполнения будущего общего блока профиля; доставка из Bitrix24 остаётся вне scope.
В MVP блок профиля «Документы» и API `GET /api/v1/me/documents` зарезервированы; список может быть пустым. Контракт endpoint — в [`arch-02-api-contracts.md`](arch-02-api-contracts.md).
Скачивание выполняется owner-only по короткому presigned GET с audit. Для вида с `hide_on_document_download=true` первое скачивание **любого** связанного документа атомарно скрывает уведомление; последующие скачивания не меняют состояние. Если `date_expired` уже задано, оно сохраняется; TTL скрытия устанавливает дату только при её отсутствии.
## Профиль клиента
@@ -519,6 +525,7 @@ App DB — **локальный кэш** для UI. Двусторонний syn
- Путь входит в `/api/*`; отдельный location `/realtime/*` в nginx **не** нужен.
- Fallback: polling `GET /api/v1/dialogs/{dialog_id}/messages?after=...`.
- События: новое сообщение, смена `delivery_status` / `safety_status`, смена `Dialog.status`.
- Подписка расширена опциональным `notifications` (default `false`); канал пользователя передаёт `notification.created`, `notification.updated`, `notification.closed`, включая эхо инициатору. После reconnect источник истины — REST.
## Принципы безопасности
+36 -3
View File
@@ -30,6 +30,7 @@
| `BITRIX_SYNC_SERVICE_TOKEN` | `bitrix-sync` | ops / мониторинг | `GET /internal/sync/v1/*` | `Authorization: Bearer` или `X-Service-Token` |
| `KEYCLOAK_SETTINGS_BRIDGE_TOKEN` | `api-backend` | Keycloak SPI | `GET /internal/settings/v1/otp` | `Authorization: Bearer` |
| `SMS_SERVICE_TOKEN` | `sms-service` | Keycloak SPI | `POST/GET /internal/sms/v1/*` | `Authorization: Bearer` |
| `NOTIFICATIONS_TOKEN_<SOURCE>` | `api-backend` | соответствующий продюсер | `POST /internal/notifications/v1/*` | `Authorization: Bearer` |
Пары значений (должны совпадать):
@@ -39,6 +40,8 @@
Генерация: `openssl rand -hex 32`. Секреты не коммитить.
Для Notifications токен отдельный на каждый `source`: секрет существует только в deployment secret/env, а `notification_sources` хранит только hash. Токен разрешает identity продюсера и сравнивается constant-time; `source` в body обязан совпасть. Seed-источник `producer_test` и `NOTIFICATIONS_TOKEN_PRODUCER_TEST` предназначены для smoke Create/Cancel, не для бизнес-интеграции.
**Не путать с webhook-токенами** (публичные callback от Bitrix24, не internal service API):
| Переменная | Назначение |
@@ -68,6 +71,15 @@
| `POST /api/v1/dialogs/{dialog_id}/attachments/{attachment_id}/complete` | `api-backend` | Expo frontend | Подтверждение загрузки, проверка объекта в quarantine, фиксация checksum/metadata | JWT |
| `GET /api/v1/dialogs/{dialog_id}/attachments/{attachment_id}/download-url` | `api-backend` | Expo frontend | Presigned URL вложения чата; обязателен audit | JWT |
| `WS /api/v1/realtime` | `api-backend` | Expo frontend | Realtime-события чата, статусы доставки, unread | JWT |
| `GET /api/v1/public/notifications` | `api-backend` | Expo frontend | Активные гостевые кампании G | public + CORS/rate limit |
| `GET /api/v1/public/notification-types` | `api-backend` | Expo frontend | Публичный каталог видов с ETag, без серверных правил переходов | public + cache/rate limit |
| `GET /api/v1/notifications?place=home\|center` | `api-backend` | Expo frontend | Персональная выборка P с серверными лимитами 7/15 и сортировкой | JWT |
| `GET /api/v1/notifications/counter` | `api-backend` | Expo frontend | Счётчик непрочитанных в окне Центра | JWT |
| `GET /api/v1/notifications/{id}` | `api-backend` | Expo frontend | Деталка активного собственного уведомления | JWT |
| `POST /api/v1/notifications/{id}/read|hide|cta` | `api-backend` | Expo frontend | Идемпотентные действия и CTA по каталогу | JWT + rate limit |
| `POST /api/v1/notifications/{id}/buttons/{button_code}` | `api-backend` | Expo frontend | Единое действие кнопки деталки | JWT + rate limit |
| `GET /api/v1/notifications/{id}/documents/{document_id}/download-url` | `api-backend` | Expo frontend | Presigned GET + audit; первое скачивание любого связанного документа может скрыть уведомление | JWT |
| `POST/GET/DELETE /api/v1/uploads/*` | `api-backend` | Expo frontend | Универсальные upload drafts клиента | JWT + rate limit |
Единый формат ошибки:
@@ -101,7 +113,10 @@
| `403` | `forbidden` | Доступ запрещён и ресурс не скрывается | нет |
| `404` | `not_found` | Ресурс не существует или принадлежит другому пользователю | нет |
| `409` | `idempotency_key_reused` | Тот же `Idempotency-Key` с другим fingerprint | нет |
| `409` | `notification_conflict` | `(source, external_id)` уже занят Create с другим fingerprint | нет |
| `409` | `notification_closed` | Действие по уже закрытому уведомлению | нет |
| `422` | `message_blocked` | Message Safety вернул final deny | нет |
| `422` | `button_not_allowed` | Кнопка не привязана к виду уведомления | нет |
| `429` | `rate_limit_exceeded` | Edge/API лимит превышен; должен быть `Retry-After`, если повтор допустим | да |
| `503` | `dependency_unavailable` | Circuit open или недоступны safety/Bitrix/S3 | да |
| `504` | `dependency_timeout` | Истёк timeout budget внешней зависимости | да |
@@ -313,10 +328,10 @@ Transport: **WebSocket** over HTTPS (`wss://`), JWT в query `?access_token=` и
3. Клиент отправляет подписку:
```json
{ "type": "subscribe", "dialog_ids": ["uuid"] }
{ "type": "subscribe", "dialog_ids": ["uuid"], "notifications": true }
```
4. Сервер отвечает `{ "type": "subscribed", "dialog_ids": ["uuid"] }`.
4. Сервер отвечает `{ "type": "subscribed", "dialog_ids": ["uuid"], "notifications": true }`. Поле `notifications` опционально, default `false`; старые chat-клиенты совместимы.
**События сервер → клиент:**
@@ -325,12 +340,17 @@ Transport: **WebSocket** over HTTPS (`wss://`), JWT в query `?access_token=` и
| `message.new` | Новое сообщение в диалоге | `dialog_id`, `message` (DTO как в REST) |
| `message.status` | Смена `safety_status` / `delivery_status` | `dialog_id`, `message_id`, `safety_status`, `delivery_status` |
| `dialog.status` | Смена `Dialog.status` | `dialog_id`, `status` |
| `notification.created` | Создано персональное уведомление | `event_id`, `occurred_at`, `notification`, `unread_count` |
| `notification.updated` | Изменено состояние/документы уведомления | `event_id`, `occurred_at`, `notification_id`, изменённые поля, `unread_count` |
| `notification.closed` | Уведомление закрыто | `event_id`, `occurred_at`, `notification_id`, `close_reason`, `unread_count` |
**Reconnect:**
- exponential backoff: 1s → 2s → 4s → … max 30s;
- после reconnect — повтор `subscribe` с актуальным списком `dialog_ids`;
- при недоступности WS > 30s — fallback на polling `GET .../messages?after=<cursor>`.
- при недоступности WS > 30s — fallback на polling чата и, при подписке на уведомления, `GET /api/v1/notifications` + `/counter` раз в 60 секунд.
События уведомлений публикуются в `han:rt:user:{user_id}` на все соединения, включая инициатора. Массовый expire job не отправляет событие на каждую запись; reconnect/polling всегда выполняет REST reconcile.
**Ping:** сервер может слать `{ "type": "ping" }` каждые 30s; клиент отвечает `{ "type": "pong" }`.
@@ -350,6 +370,19 @@ Transport: **WebSocket** over HTTPS (`wss://`), JWT в query `?access_token=` и
- internal API версионируется тем же правилом (`/internal/{mnemonic}/v2/...`);
- OpenAPI генерируется или поддерживается вручную — на усмотрение модуля, но файл обязателен в DoD (arch-05).
## Producers ↔ api-backend: Notifications
| Контракт | Назначение | Защита |
|---|---|---|
| `POST /internal/notifications/v1/notifications` | Create персонального уведомления | private network + Bearer token конкретного `source` |
| `POST /internal/notifications/v1/notifications/cancel` | Cancel по `(source, external_id)` с `cancelled` или `paid` | то же |
Пара `(source, external_id)` уникальна бессрочно и заменяет `Idempotency-Key`: одинаковый canonical fingerprint возвращает существующую запись с `200`, другой — `409 notification_conflict`. Cancel идемпотентен; чужой `source` не раскрывается.
Каталог, валидация `details`, обязательных полей CTA и эффектов кнопок применяются по данным справочников без ветвления по `notification_type`. Инструкция `install_app` всегда возвращает открытие `instruction_url` в новой вкладке, без iframe/модалки.
При скрытии действие всегда ставит `visibility=hidden`. TTL из вида/default применяется только если `date_expired IS NULL`; уже заданная продюсером дата сохраняется. Первое успешное получение download URL для **любого** связанного документа считается началом скачивания и, при `hide_on_document_download=true`, один раз скрывает уведомление; последующие документы состояние не меняют.
## Keycloak SPI ↔ api-backend settings bridge
Keycloak SPI получает product limits OTP из `app_settings` через internal endpoint, а не через прямой доступ к `han_app`.
@@ -23,7 +23,7 @@
- `/bitrix/sync/*` (public: webhook CRM sync для `bitrix-sync`) → `bitrix-sync`;
- exact `POST /callbacks/idgtl/sms``sms-service`; остальные методы и SMS paths не публикуются;
- web-сборка frontend или прокси на dev-сервер;
- `/internal/openlines/*`, `/internal/safety/*`, `/internal/sync/*`, `/internal/sms/*` **не публикуются** наружу — доступны только из внутренней Docker-сети.
- `/internal/openlines/*`, `/internal/safety/*`, `/internal/sync/*`, `/internal/sms/*`, `/internal/notifications/*` **не публикуются** наружу — доступны только из внутренней Docker-сети.
- Никакой другой `nginx` (ни в контейнере сервиса, ни на хосте) не терминирует внешний HTTPS для backend-контура. Site-конфиг `tohin.ru` на хосте, если используется, должен проксировать весь трафик на корневой `nginx` контейнера, а не на порты отдельных сервисов напрямую.
### Структура compose через `include`
@@ -158,6 +158,8 @@ Python FastAPI backend.
- принимает forward нормализованных событий оператора от `bitrix-local-app`;
- поддерживает realtime endpoint для сообщений оператора;
- работает с Selectel S3 для файлов и документов;
- обслуживает Notification Center G/P и Internal Create/Cancel; token каждого продюсера передаётся через env/secret, в App DB хранится только hash;
- имеет отдельные процессы expire job (ежедневно 00:01 UTC, advisory lock) и cleanup upload drafts/S3; они используют тот же immutable image и не публикуют порты;
- экспортирует traces/logs в `otel-collector`;
- не хранит состояние внутри контейнера.
@@ -300,6 +302,8 @@ Identity provider. **Обязателен** в compose-контуре с пер
Корневой `backend/.env` читается всеми сервисами compose через `${VAR}` в сервисных `docker-compose.yml`. Канонический `.env.example` и `app_settings` — в [`arch-04-settings-and-content.md`](arch-04-settings-and-content.md); контракты service tokens — в [`arch-02-api-contracts.md`](arch-02-api-contracts.md).
Для smoke-продюсера обязателен `NOTIFICATIONS_TOKEN_PRODUCER_TEST`; это secret, а не `app_settings`. Compose передаёт его только `api-backend` и notification workers. Зарегистрированные entrypoints: `han-notification-expire-worker` и `han-notification-draft-cleanup-worker`; выдуманный command без project script в deployment запрещён.
## HTTPS и TLS
Соответствует [`arch-01-system-architecture.md`](arch-01-system-architecture.md), «Принципы безопасности» (HTTPS, TLS, HSTS). Инфраструктурная реализация:
@@ -330,6 +334,7 @@ Identity provider. **Обязателен** в compose-контуре с пер
- слабые шифры запрещены на уровне `nginx`;
- `nginx` скрывает `Server`, `X-Powered-By` и аналогичные технологические заголовки;
- security headers: `Strict-Transport-Security`, `X-Content-Type-Options`, `Referrer-Policy`, `Content-Security-Policy` для web-приложения;
- инструкция по установке всегда открывается новой вкладкой, поэтому CSP SPA задаёт `frame-src 'none'`; allow-list iframe для инструкций отсутствует;
- секретный ключ сертификата не коммитится в репозиторий;
- использовать сертификаты доверенного CA; автоматизировать выпуск и продление (Let's Encrypt + reload `nginx`);
- закрыть прямой доступ к внутренним портам контейнеров извне.
@@ -367,7 +372,7 @@ Rate limits должны быть распределены по двум сло
`nginx`:
- ограничивает частоту запросов до попадания в API;
- держит отдельные зоны лимитов для `/auth`, `/api`, public endpoints, fallback polling и download endpoints;
- держит отдельные зоны лимитов для `/auth`, `/api`, public endpoints, fallback polling, download endpoints, чтения/действий/загрузок Notification Center;
- ограничивает `client_max_body_size`;
- ограничивает загрузку файлов лимитом 5 МБ; `client_max_body_size` должен быть чуть выше бизнес-лимита для учета overhead запроса;
- применяет `limit_req` для endpoint авторизации и fallback polling;
@@ -435,7 +440,8 @@ WAF не заменяет обязательные лимиты, валидац
6. `message-safety`.
7. `bitrix-local-app`.
8. `bitrix-sync`.
9. `nginx`.
9. Notification expire/cleanup workers после готовности `api-backend` и регистрации их entrypoints.
10. `nginx`.
Порядок rollout SMS подробнее задаёт module-11/module-10. Зависимости запуска не образуют цикл: Keycloak стартует при недоступном `sms-service`; это блокирует только новые real-mode orders, а verify уже active challenges продолжается по snapshot.
@@ -92,6 +92,8 @@ Managed PostgreSQL **поднимается до** развёртывания п
| Consent | `consent.personal_data.*`, `consent.privacy_policy.document_url`, `consent.user_agreement.*`, `consent.marketing.*` |
| Файлы чата | `chat.attachments.*` |
| Rate limits (app) | `rate_limit.message_send.*`, `rate_limit.download_url.*`, `rate_limit.public_endpoints.*`, `rate_limit.login.*` |
| Notification Center | `notification.home.max_items`, `notification.center.max_items`, `notification.carousel.*`, `notification.hidden.default_ttl_days`, `notification.documents.max_files`, `notification.expire_job.run_at`, `notification.upload_draft.ttl_days` |
| Rate limits (notifications) | `rate_limit.notifications_read.per_user`, `rate_limit.notifications_action.per_user`, `rate_limit.notification_upload.per_user`, `rate_limit.notifications_public.per_ip` |
| UX | `ux.session.idle_timeout_minutes` |
| Security | `security.cors.allowed_origins`, `security.public_cache.max_age_seconds` |
@@ -135,6 +137,19 @@ rate_limit.message_send.per_dialog=20/minute
rate_limit.download_url.per_user=60/hour
rate_limit.public_endpoints.per_ip=60/minute
rate_limit.login.per_ip=10/minute
rate_limit.notifications_read.per_user=120/minute
rate_limit.notifications_action.per_user=60/minute
rate_limit.notification_upload.per_user=20/minute
rate_limit.notifications_public.per_ip=60/minute
notification.home.max_items=7
notification.center.max_items=15
notification.carousel.autoplay_enabled=false
notification.carousel.autoplay_interval_ms=5000
notification.hidden.default_ttl_days=3
notification.documents.max_files=10
notification.expire_job.run_at=00:01
notification.upload_draft.ttl_days=7
ux.session.idle_timeout_minutes=30
@@ -217,6 +232,10 @@ NGINX_RATE_LIMIT_AUTH=10r/m
NGINX_RATE_LIMIT_DOWNLOADS=30r/m
NGINX_RATE_LIMIT_PUBLIC=60r/m
NGINX_RATE_LIMIT_POLLING=60r/m
NGINX_RATE_LIMIT_NOTIFICATIONS_READ=120r/m
NGINX_RATE_LIMIT_NOTIFICATIONS_ACTION=60r/m
NGINX_RATE_LIMIT_NOTIFICATION_UPLOAD=20r/m
NGINX_RATE_LIMIT_NOTIFICATIONS_PUBLIC=60r/m
# =============================================================================
# Keycloak (mock остаётся true до controlled SMS cutover)
@@ -254,6 +273,7 @@ BITRIX_SYNC_SERVICE_TOKEN=change-me
KEYCLOAK_SETTINGS_BRIDGE_TOKEN=change-me
SMS_SERVICE_TOKEN=change-me
KEYCLOAK_SMS_SERVICE_TOKEN=change-me
NOTIFICATIONS_TOKEN_PRODUCER_TEST=change-me
# =============================================================================
# SMS provider (URL и секреты; runtime-параметры — sms.sms_setting)
@@ -345,6 +365,8 @@ presigned URL и CORS Selectel; path-style адресация не поддер
**Webhook-токены** (публичные callback, не service API): `BITRIX_APPLICATION_TOKEN`, `BITRIX_SYNC_WEBHOOK_TOKEN`.
`NOTIFICATIONS_TOKEN_<SOURCE>` — индивидуальный секрет продюсера Internal Notifications API. Для seed/smoke используется `NOTIFICATIONS_TOKEN_PRODUCER_TEST`; secret хранится только в deployment env/secret, а `notification_sources.token_hash` — только hash. Инструкция не имеет `notification.instruction.allowed_hosts`: она всегда открывается в новой вкладке, iframe-режима нет.
## Namespace переменных Bitrix
- `bitrix-local-app`: `BITRIX_CLIENT_*`, `BITRIX_CONNECTOR_*`, `BITRIX_PUBLIC_BASE_URL`, `BITRIX_DATABASE_URL`, `BITRIX_API_FORWARD_URL`, `BITRIX_APPLICATION_TOKEN` + service tokens.
@@ -20,6 +20,7 @@
- Перечень таблиц, полей, индексов и миграций **определяет модуль-владелец** (`database`, `api-backend`, `bitrix-sync`, `message-safety`, `bitrix-local-app`), а не arch-*.
- Архитектура фиксирует **разделение схем** и общие подходы к ведению баз данных, которые должны соблюдаться при проработке модулей.
- У каждой основной **прикладной** сущности должен быть `record_status`. Базовые статусы: `A` — active, `D` — deleted.
- `record_status` выражает только административное наличие строки. Доменное завершение (например `Notification.lifecycle_status='closed'`) хранится отдельно и не переводит запись в `D`.
- Физическое удаление строк прикладных сущностей запрещено. Если нужно удалить сущность, сервис меняет `record_status` с `A` на `D`.
- При смене статуса на `D` сервис обязан заполнить `status_changed_at` и `status_change_reason`.
- Все сервисы при чтении бизнес-данных по умолчанию запрашивают только `record_status = 'A'`.
@@ -74,6 +75,8 @@ Raw OTP запрещено хранить в открытом виде: это
- поведение при повторной доставке webhook;
- таймауты и retry/backoff.
Для Notification Center обязательны contract tests каталога без ветвления по виду, бессрочной дедупликации `(source, external_id)`, изоляции producer tokens, TTL с сохранением существующего `date_expired`, первого скачивания любого связанного документа и открытия instruction только в новой вкладке.
## Definition of Done
Модуль считается готовым, если:
@@ -85,6 +88,7 @@ Raw OTP запрещено хранить в открытом виде: это
- обновлены seed `app_settings` и `.env.example`, если добавлялись настройки, service tokens, лимиты или feature flags;
- добавлены тесты;
- сервис запускается в Docker Compose;
- worker, указанный в Compose/runbook, имеет реально зарегистрированный entrypoint в image; deployment не может заранее выдумывать имя команды;
- все изменяемые параметры вынесены из кода;
- логи содержат `request_id`, `trace_id` и **`ux_session_id`** (если передан в запросе);
- нет секретов, raw OTP и PII в логах;
+4 -1
View File
@@ -22,6 +22,8 @@
21. UX-дефект: frontend показывает «Не удалось завершить вход» при ошибке отправки отложенного сообщения, хотя вход завершён. Это следует исправить: завершать экран авторизации после bootstrap, а ошибку Bitrix показывать уже в чате.
22. Веб-пуши для PWA
23. На кнопке Чат отображать значок наличия непрочитанных уведомлений. Требуется синхронизация между устройствами (решение, например через Dialog.client_last_opened_at)
24. На главном экране две кнопки: чат и звонок оператору. На кнопке с чатом уведомление при наличии непрочитанных сообщений.
25. Поменять функционал карточке: сейчас слайдер, нужна карусель со стрелками (либо какое-то комбо - подобрать в фигма.)
На будущее (после доработки отдельных функциональностей):
1. Разработка message-safety
@@ -29,8 +31,9 @@
~~3. Интеграция с СМС-провайдером — спецификация и план rollout зафиксированы в `modules/module-11-idgtl-sms.md`; пункт не закрыт до реализации `sms-service`/worker, Keycloak lifecycle, schema `sms`, callback/nginx, env validation, observability и общего DoD. Production prerequisites: согласованные sender/template, Direct `TOKEN_1`, callback credentials/подтверждённый source IP и статический egress IP.~~
3. Определение итогового перечня мнемоник, перевод фронтенда на мнемоники, seed заливка мнемоник в БД (?)
4. Моделирование профиля клиента/
5. Моделирование уведомлений — постановка v6 (G/P; каталог видов как данные; D1–D34 закрыты, открытых вопросов нет): `busines_tasks/notification-requirements.md` arch-00/01/02/03/04/05, module-01/03/07.
~~5. Моделирование уведомлений — постановка v6 синхронизирована с `functional_blocks (business logic)/notification-requirements.md`, arch-00…05 и module-01/03/07. Зафиксированы: instruction только в новой вкладке; TTL только при отсутствии `date_expired`; первое скачивание любого связанного документа скрывает; `producer_test` только для smoke, secret/hash раздельно.~~
6. Реализация мнемоник.
7. Реализовать в полноценном `bitrix-sync` обработчик `document.client_uploaded`: claim/retry/DLQ, идемпотентность по `client_document_id`, группировка по `submission_id`; до этого stub задачи не claim-ит.
На анализ:
debounce на отправку СМС (сейчас есть Фиксированный cooldownmin_seconds_between_attempts)
+6
View File
@@ -46,6 +46,10 @@ NGINX_RATE_LIMIT_AUTH=60r/m
NGINX_RATE_LIMIT_PUBLIC=60r/m
NGINX_RATE_LIMIT_POLLING=60r/m
NGINX_RATE_LIMIT_DOWNLOADS=30r/m
NGINX_RATE_LIMIT_NOTIFICATIONS_READ=120r/m
NGINX_RATE_LIMIT_NOTIFICATIONS_ACTION=60r/m
NGINX_RATE_LIMIT_NOTIFICATION_UPLOAD=20r/m
NGINX_RATE_LIMIT_NOTIFICATIONS_PUBLIC=60r/m
NGINX_RATE_LIMIT_BITRIX=120r/m
NGINX_RATE_LIMIT_SMS_CALLBACK=120r/m
NGINX_RATE_LIMIT_WS=30r/m
@@ -112,6 +116,8 @@ BITRIX_SYNC_SERVICE_TOKEN=change-me
KEYCLOAK_SETTINGS_BRIDGE_TOKEN=change-me
#token6 (openssl rand -hex 32), должен совпадать с KEYCLOAK_SMS_SERVICE_TOKEN
SMS_SERVICE_TOKEN=change-me
# Тестовый продюсер Internal Notifications API. В БД хранится только hash.
NOTIFICATIONS_TOKEN_PRODUCER_TEST=change-me
# i-Digital Direct. Перед production заменить placeholders согласованными значениями.
IDGTL_SMS_BASE_URL=https://direct.i-dgtl.ru
+11
View File
@@ -22,6 +22,8 @@ Workers запускаются независимо:
han-delivery-worker
han-safety-worker
han-cleanup-worker
han-notification-expire-worker
han-notification-draft-cleanup-worker
```
## Переменные окружения
@@ -49,12 +51,21 @@ han-cleanup-worker
`SELECTEL_S3_BUCKET_ATTACHMENTS`, `SELECTEL_S3_BUCKET_QUARANTINE`,
`SELECTEL_S3_ACCESS_KEY`, `SELECTEL_S3_SECRET_KEY`;
- `CURSOR_HMAC_SECRET` — случайный секрет не короче 32 байт;
- `NOTIFICATIONS_TOKEN_PRODUCER_TEST` — отдельный bearer token тестового
продюсера Notification Center; в БД синхронизируется только SHA-256 hash;
- `OTEL_EXPORTER_OTLP_ENDPOINT` — опциональный endpoint collector.
Токены генерируются `openssl rand -hex 32`. S3 read-only credentials Message Safety
не передаются этому контейнеру. В production подключение PostgreSQL должно использовать
TLS, а internal endpoints — быть доступны только из backend-сети.
Smoke-сценарий `producer_test`: отправить `POST
/internal/notifications/v1/notifications` с `Authorization: Bearer
$NOTIFICATIONS_TOKEN_PRODUCER_TEST`, `source=producer_test` и уникальным
`external_id`; повтор того же тела вернёт `200`. Затем передать ту же пару
`source`/`external_id` в `POST /internal/notifications/v1/notifications/cancel`
с `close_reason=cancelled`; повторная отмена также вернёт `200`.
## Проверки
```bash
@@ -0,0 +1,651 @@
"""Notification Center v1 schema, catalog and settings.
Revision ID: 0008_notifications_v1
Revises: 0007_marketing_doc
Create Date: 2026-07-27
"""
import hashlib
import os
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0008_notifications_v1"
down_revision: str | None = "0007_marketing_doc"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
COMMON = """
record_status varchar(1) NOT NULL DEFAULT 'A',
status_changed_at timestamptz NULL,
status_change_reason varchar(255) NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
updater_user_id uuid NULL
"""
def _execute_batch(sql: str) -> None:
"""Execute one statement at a time for the asyncpg prepared-statement dialect."""
statement: list[str] = []
in_dollar_quote = False
index = 0
while index < len(sql):
if sql[index : index + 2] == "$$":
in_dollar_quote = not in_dollar_quote
statement.append("$$")
index += 2
continue
character = sql[index]
if character == ";" and not in_dollar_quote:
value = "".join(statement).strip()
if value:
op.execute(value)
statement.clear()
else:
statement.append(character)
index += 1
value = "".join(statement).strip()
if value:
op.execute(value)
def upgrade() -> None:
bind = op.get_bind()
_execute_batch(
f"""
CREATE TABLE han_app.notification_cta_actions (
id uuid PRIMARY KEY, code varchar(32) NOT NULL UNIQUE,
description varchar(255) NOT NULL, requires_auth boolean NOT NULL,
required_instance_fields varchar(64)[] NOT NULL DEFAULT '{{}}', {COMMON}
);
CREATE TABLE han_app.notification_buttons (
id uuid PRIMARY KEY, code varchar(32) NOT NULL UNIQUE,
label varchar(64) NOT NULL, sets_hidden boolean NOT NULL DEFAULT false,
applies_hidden_ttl boolean NOT NULL DEFAULT false,
close_reason varchar(32), submits_documents boolean NOT NULL DEFAULT false,
{COMMON},
CHECK (NOT applies_hidden_ttl OR sets_hidden),
CHECK (NOT submits_documents OR close_reason IS NOT NULL)
);
CREATE TABLE han_app.notification_color_tokens (
id uuid PRIMARY KEY, code varchar(32) NOT NULL UNIQUE,
description varchar(255) NOT NULL, sort_order smallint NOT NULL, {COMMON}
);
CREATE TABLE han_app.notification_types (
id uuid PRIMARY KEY, code varchar(32) NOT NULL UNIQUE,
contour varchar(1) NOT NULL CHECK (contour IN ('G','P')),
priority smallint NOT NULL, countable boolean NOT NULL,
label varchar(64) NOT NULL,
color_token varchar(32) NOT NULL REFERENCES han_app.notification_color_tokens(code)
ON DELETE RESTRICT,
icon_code varchar(32), cta_text varchar(64) NOT NULL,
cta_action varchar(32) NOT NULL REFERENCES han_app.notification_cta_actions(code)
ON DELETE RESTRICT,
cta_sets_hidden boolean NOT NULL DEFAULT false,
cta_close_reason varchar(32),
button_primary_code varchar(32) REFERENCES han_app.notification_buttons(code)
ON DELETE RESTRICT,
button_secondary_code varchar(32) REFERENCES han_app.notification_buttons(code)
ON DELETE RESTRICT,
hidden_ttl_days smallint,
documents_allowed boolean NOT NULL DEFAULT false,
hide_on_document_download boolean NOT NULL DEFAULT false,
required_detail_blocks varchar(64)[] NOT NULL DEFAULT '{{}}',
{COMMON},
CHECK (contour <> 'G' OR countable = false),
CHECK (contour <> 'G' OR cta_action <> 'open_detail'),
CHECK (cta_action <> 'open_detail' OR
(cta_sets_hidden = false AND cta_close_reason IS NULL)),
CHECK (cta_action = 'open_detail' OR
(documents_allowed = false AND hide_on_document_download = false
AND required_detail_blocks = '{{}}')),
CHECK (NOT hide_on_document_download OR documents_allowed),
CHECK (cta_action <> 'open_detail' OR button_primary_code IS NOT NULL),
CHECK (cta_action = 'open_detail' OR
(button_primary_code IS NULL AND button_secondary_code IS NULL)),
CHECK (button_secondary_code IS NULL OR button_primary_code IS NOT NULL),
CHECK (button_secondary_code IS NULL OR
button_secondary_code <> button_primary_code)
);
CREATE TABLE han_app.notification_sources (
id uuid PRIMARY KEY, code varchar(32) NOT NULL UNIQUE,
description text, token_hash varchar(128) NOT NULL UNIQUE,
token_rotated_at timestamptz, {COMMON}
);
CREATE TABLE han_app.notifications (
id uuid PRIMARY KEY,
user_id uuid NOT NULL REFERENCES han_app.user_identities(id) ON DELETE RESTRICT,
notification_type varchar(32) NOT NULL
REFERENCES han_app.notification_types(code) ON DELETE RESTRICT,
source varchar(32) NOT NULL
REFERENCES han_app.notification_sources(code) ON DELETE RESTRICT,
external_id varchar(128) NOT NULL,
request_fingerprint varchar(64) NOT NULL,
notification_datetime timestamptz NOT NULL,
header varchar(255) NOT NULL, text varchar(1024),
priority_override smallint, date_expired timestamptz,
price numeric(12,2), old_price numeric(12,2), payment_url text,
details jsonb, details_schema_version smallint NOT NULL DEFAULT 1,
chat_message_text varchar(1024),
lifecycle_status varchar(16) NOT NULL DEFAULT 'active'
CHECK (lifecycle_status IN ('active','closed')),
visibility varchar(16) NOT NULL DEFAULT 'visible'
CHECK (visibility IN ('visible','hidden')),
is_read boolean NOT NULL DEFAULT false,
close_reason varchar(32) CHECK (close_reason IS NULL OR close_reason IN
('user_done','docs_submitted','offer_accepted','paid','expired','cancelled')),
closed_at timestamptz, {COMMON},
CONSTRAINT uq_notifications_source_key UNIQUE (source, external_id),
CHECK (old_price IS NULL OR price IS NOT NULL),
CHECK (lifecycle_status <> 'closed' OR
(close_reason IS NOT NULL AND closed_at IS NOT NULL))
);
CREATE INDEX ix_notifications_user_active
ON han_app.notifications
(user_id, lifecycle_status, visibility, notification_datetime DESC, id DESC)
WHERE record_status='A';
CREATE INDEX ix_notifications_expire ON han_app.notifications(date_expired)
WHERE record_status='A' AND lifecycle_status='active'
AND date_expired IS NOT NULL;
CREATE TABLE han_app.guest_notifications (
id uuid PRIMARY KEY,
notification_type varchar(32) NOT NULL
REFERENCES han_app.notification_types(code) ON DELETE RESTRICT,
notification_datetime timestamptz NOT NULL,
header varchar(255) NOT NULL, text varchar(1024),
priority_override smallint, date_expired timestamptz,
price numeric(12,2), old_price numeric(12,2),
instruction_url text, chat_message_text varchar(1024),
lifecycle_status varchar(16) NOT NULL DEFAULT 'active'
CHECK (lifecycle_status IN ('active','closed')),
closed_at timestamptz, {COMMON},
CHECK (old_price IS NULL OR price IS NOT NULL),
CHECK (instruction_url IS NULL OR instruction_url LIKE 'https://%')
);
CREATE INDEX ix_guest_notifications_active
ON han_app.guest_notifications(lifecycle_status, notification_datetime DESC, id DESC)
WHERE record_status='A';
CREATE INDEX ix_guest_notifications_expire
ON han_app.guest_notifications(date_expired)
WHERE record_status='A' AND lifecycle_status='active'
AND date_expired IS NOT NULL;
CREATE TABLE han_app.notification_documents (
id uuid PRIMARY KEY,
notification_id uuid NOT NULL
REFERENCES han_app.notifications(id) ON DELETE RESTRICT,
document_id uuid NOT NULL REFERENCES han_app.documents(id) ON DELETE RESTRICT,
sort_order smallint NOT NULL DEFAULT 0,
download_url_issued_at timestamptz, {COMMON},
UNIQUE (notification_id, document_id)
);
CREATE TABLE han_app.client_upload_drafts (
id uuid PRIMARY KEY,
user_id uuid NOT NULL REFERENCES han_app.user_identities(id) ON DELETE RESTRICT,
context_type varchar(32) NOT NULL CHECK (context_type IN ('notification')),
context_id uuid NOT NULL,
original_file_name varchar(255) NOT NULL,
safe_file_name varchar(255) NOT NULL,
mime_type varchar(128) NOT NULL,
size_bytes bigint NOT NULL CHECK (size_bytes > 0),
checksum_sha256 char(64),
scan_status varchar(16) NOT NULL DEFAULT 'pending'
CHECK (scan_status IN ('pending','clean','infected','failed')),
storage_bucket varchar(255) NOT NULL,
object_key varchar(1024) NOT NULL,
quarantine_object_key varchar(1024),
upload_expires_at timestamptz, completed_at timestamptz,
state varchar(16) NOT NULL DEFAULT 'draft'
CHECK (state IN ('draft','submitted','discarded')),
submission_id uuid,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX ix_client_upload_drafts_context
ON han_app.client_upload_drafts(user_id, context_type, context_id)
WHERE state='draft';
CREATE INDEX ix_client_upload_drafts_scan
ON han_app.client_upload_drafts(scan_status, updated_at);
CREATE INDEX ix_client_upload_drafts_created
ON han_app.client_upload_drafts(created_at);
CREATE TABLE han_app.client_documents (
id uuid PRIMARY KEY,
user_id uuid NOT NULL REFERENCES han_app.user_identities(id) ON DELETE RESTRICT,
context_type varchar(32) NOT NULL, context_id uuid NOT NULL,
submission_id uuid NOT NULL, source_draft_id uuid NOT NULL UNIQUE,
original_file_name varchar(255) NOT NULL, safe_file_name varchar(255) NOT NULL,
mime_type varchar(128) NOT NULL, size_bytes bigint NOT NULL,
checksum_sha256 char(64) NOT NULL, storage_bucket varchar(255) NOT NULL,
object_key varchar(1024) NOT NULL, submitted_at timestamptz NOT NULL,
{COMMON}, UNIQUE (storage_bucket, object_key)
);
CREATE INDEX ix_client_documents_context
ON han_app.client_documents(context_type, context_id);
CREATE INDEX ix_client_documents_user_submitted
ON han_app.client_documents(user_id, submitted_at DESC);
"""
)
_execute_batch(
"""
CREATE OR REPLACE FUNCTION han_app.validate_guest_notification()
RETURNS trigger LANGUAGE plpgsql SECURITY INVOKER
SET search_path = han_app, pg_temp AS $$
DECLARE v_contour varchar(1); v_action varchar(32); v_required varchar(64)[];
BEGIN
SELECT nt.contour, nt.cta_action, ca.required_instance_fields
INTO v_contour, v_action, v_required
FROM han_app.notification_types nt
JOIN han_app.notification_cta_actions ca ON ca.code=nt.cta_action
WHERE nt.code=NEW.notification_type AND nt.record_status='A';
IF v_contour IS DISTINCT FROM 'G' THEN
RAISE EXCEPTION 'notification type must use guest contour';
END IF;
IF ('instruction_url'=ANY(v_required)) <> (NEW.instruction_url IS NOT NULL)
OR ('chat_message_text'=ANY(v_required)) <>
(NEW.chat_message_text IS NOT NULL) THEN
RAISE EXCEPTION 'guest notification fields do not match CTA';
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER trg_validate_guest_notification
BEFORE INSERT OR UPDATE ON han_app.guest_notifications
FOR EACH ROW EXECUTE FUNCTION han_app.validate_guest_notification();
CREATE OR REPLACE FUNCTION han_app.enqueue_client_document_sync()
RETURNS trigger LANGUAGE plpgsql SECURITY INVOKER
SET search_path = han_app, pg_temp AS $$
BEGIN
IF current_setting('han.sync_suppress', true) = 'true' THEN RETURN NEW; END IF;
INSERT INTO han_app.sync_queue
(id, task_type, entity_type, entity_id, dedup_key, payload_json,
status, attempt_count, next_attempt_at, created_at, updated_at)
VALUES (
gen_random_uuid(), 'document.client_uploaded', 'client_document', NEW.id,
'document.client_uploaded:' || NEW.id::text,
jsonb_build_object(
'client_document_id', NEW.id, 'user_id', NEW.user_id,
'context_type', NEW.context_type, 'context_id', NEW.context_id,
'submission_id', NEW.submission_id, 'storage_bucket', NEW.storage_bucket,
'object_key', NEW.object_key, 'original_file_name', NEW.original_file_name,
'mime_type', NEW.mime_type, 'size_bytes', NEW.size_bytes,
'checksum_sha256', NEW.checksum_sha256),
'pending', 0, now(), now(), now())
ON CONFLICT (dedup_key) DO NOTHING;
RETURN NEW;
END $$;
CREATE TRIGGER trg_client_document_sync
AFTER INSERT ON han_app.client_documents
FOR EACH ROW WHEN (NEW.record_status='A')
EXECUTE FUNCTION han_app.enqueue_client_document_sync();
"""
)
_seed_catalog(bind)
_seed_settings(bind)
def _seed_catalog(bind: sa.Connection) -> None:
# asyncpg rejects multiple SQL commands in one prepared statement.
_execute_batch(
"""
INSERT INTO han_app.notification_cta_actions
(id,code,description,requires_auth,required_instance_fields)
VALUES
(gen_random_uuid(),'open_detail','Open notification detail',true,ARRAY['details']),
(gen_random_uuid(),'open_payment_url','Open payment URL',true,ARRAY['payment_url']),
(gen_random_uuid(),'send_chat_message','Send prepared chat message',true,
ARRAY['chat_message_text']),
(gen_random_uuid(),'start_auth','Start authentication',false,ARRAY[]::varchar[]),
(gen_random_uuid(),'install_app_prompt','Install application or open instruction',
false,ARRAY['instruction_url']);
INSERT INTO han_app.notification_buttons
(id,code,label,sets_hidden,applies_hidden_ttl,close_reason,submits_documents)
VALUES
(gen_random_uuid(),'done','Готово',false,false,'user_done',false),
(gen_random_uuid(),'later','Сделаю позже',false,false,NULL,false),
(gen_random_uuid(),'gotit','Понятно',true,true,NULL,false),
(gen_random_uuid(),'send_docs','Отправить документы',false,false,
'docs_submitted',true);
INSERT INTO han_app.notification_color_tokens
(id,code,description,sort_order)
VALUES
(gen_random_uuid(),'critical','Requires immediate attention',1),
(gen_random_uuid(),'warning','Waiting for a client action',2),
(gen_random_uuid(),'success','Successful result',3),
(gen_random_uuid(),'info','Informational notification',4),
(gen_random_uuid(),'promo','Marketing offer',5),
(gen_random_uuid(),'neutral','Neutral interface hint',6);
"""
)
types = [
(
"authorize",
"G",
1,
False,
"Гостевой режим",
"neutral",
"login",
"Войти →",
"start_auth",
False,
None,
None,
None,
False,
False,
[],
),
(
"install_app",
"G",
2,
False,
"Приложение",
"neutral",
"install",
"Установить →",
"install_app_prompt",
False,
None,
None,
None,
False,
False,
[],
),
(
"promo_global",
"G",
3,
False,
"Акция",
"promo",
"promo",
"Узнать подробнее →",
"send_chat_message",
False,
None,
None,
None,
False,
False,
[],
),
(
"ads_global",
"G",
4,
False,
"Предложение",
"promo",
"offer",
"Узнать подробнее →",
"send_chat_message",
False,
None,
None,
None,
False,
False,
[],
),
(
"urgent",
"P",
1,
True,
"Срочно",
"critical",
"urgent",
"Подробнее →",
"open_detail",
False,
None,
"done",
"later",
False,
False,
[],
),
(
"payment_pending",
"P",
2,
True,
"Оплата",
"warning",
"payment",
"Оплатить →",
"open_payment_url",
False,
None,
None,
None,
False,
False,
[],
),
(
"docs_required",
"P",
2,
True,
"Требуются документы",
"warning",
"upload",
"Загрузить документы →",
"open_detail",
False,
None,
"send_docs",
"later",
False,
False,
[],
),
(
"docs_ready",
"P",
3,
True,
"Документы готовы",
"success",
"download",
"Скачать →",
"open_detail",
False,
None,
"gotit",
None,
True,
True,
["documents"],
),
(
"status_changed",
"P",
3,
True,
"Статус",
"info",
"status",
"Подробнее →",
"open_detail",
False,
None,
"gotit",
None,
False,
False,
[],
),
(
"reminder",
"P",
3,
True,
"Напоминание",
"info",
"reminder",
"Подробнее →",
"open_detail",
False,
None,
"done",
"later",
False,
False,
[],
),
(
"news",
"P",
4,
True,
"Новость",
"info",
"news",
"Подробнее →",
"open_detail",
False,
None,
"gotit",
None,
False,
False,
[],
),
(
"promo_personal",
"P",
5,
True,
"Акция",
"promo",
"promo",
"Узнать подробнее →",
"send_chat_message",
True,
"offer_accepted",
None,
None,
False,
False,
[],
),
(
"ads_personal",
"P",
5,
True,
"Предложение",
"promo",
"offer",
"Узнать подробнее →",
"send_chat_message",
True,
"offer_accepted",
None,
None,
False,
False,
[],
),
]
statement = sa.text(
"""
INSERT INTO han_app.notification_types
(id,code,contour,priority,countable,label,color_token,icon_code,cta_text,cta_action,
cta_sets_hidden,cta_close_reason,button_primary_code,button_secondary_code,
documents_allowed,hide_on_document_download,required_detail_blocks)
VALUES
(gen_random_uuid(),:code,:contour,:priority,:countable,:label,:color,:icon,:cta_text,
:action,:sets_hidden,:close_reason,:primary,:secondary,:documents_allowed,
:hide_download,:required)
"""
)
for row in types:
bind.execute(
statement,
dict(
zip(
(
"code",
"contour",
"priority",
"countable",
"label",
"color",
"icon",
"cta_text",
"action",
"sets_hidden",
"close_reason",
"primary",
"secondary",
"documents_allowed",
"hide_download",
"required",
),
row,
strict=True,
)
),
)
token = os.getenv("NOTIFICATIONS_TOKEN_PRODUCER_TEST", "")
token_hash = (
hashlib.sha256(token.encode()).hexdigest()
if token
else hashlib.sha256(b"disabled:producer_test").hexdigest()
)
bind.execute(
sa.text(
"""
INSERT INTO han_app.notification_sources
(id,code,description,token_hash,token_rotated_at)
VALUES (gen_random_uuid(),'producer_test','Notification Center smoke producer',
:token_hash,now())
"""
),
{"token_hash": token_hash},
)
def _seed_settings(bind: sa.Connection) -> None:
settings = [
("notification.home.max_items", "7", "integer", False),
("notification.center.max_items", "15", "integer", False),
("notification.carousel.autoplay_enabled", "false", "boolean", True),
("notification.carousel.autoplay_interval_ms", "5000", "integer", True),
("notification.hidden.default_ttl_days", "3", "integer", False),
("notification.documents.max_files", "10", "integer", False),
("notification.instruction.allowed_hosts", "chat.example.ru", "string_list", False),
("notification.expire_job.run_at", "00:01", "string", False),
("notification.upload_draft.ttl_days", "7", "integer", False),
("rate_limit.notifications_read.per_user", "120/minute", "string", False),
("rate_limit.notifications_action.per_user", "60/minute", "string", False),
("rate_limit.notification_upload.per_user", "20/minute", "string", False),
("rate_limit.notifications_public.per_ip", "60/minute", "string", False),
]
statement = sa.text(
"""
INSERT INTO han_app.app_settings
(setting_key,setting_value,value_type,is_public,record_status,updated_at)
VALUES (:key,:value,:kind,:public,'A',now())
ON CONFLICT (setting_key) DO UPDATE SET setting_value=EXCLUDED.setting_value,
value_type=EXCLUDED.value_type,is_public=EXCLUDED.is_public,
record_status='A',updated_at=now()
"""
)
for key, value, kind, public in settings:
bind.execute(statement, {"key": key, "value": value, "kind": kind, "public": public})
def downgrade() -> None:
raise RuntimeError("Notification Center migration is forward-only")
@@ -295,6 +295,9 @@ class S3Client:
Key=key,
)
async def delete(self, bucket: str, key: str) -> None:
await asyncio.to_thread(self.client.delete_object, Bucket=bucket, Key=key)
async def upload_inbound(
self,
http: httpx.AsyncClient,
+23 -4
View File
@@ -50,6 +50,8 @@ from app.integrations import (
S3Client,
SafetyClient,
)
from app.notification_routes import router as notification_router
from app.notification_service import synchronize_source_tokens
from app.realtime import RealtimeFanout
from app.schemas import (
AttachmentCompleteRequest,
@@ -128,6 +130,7 @@ async def lifespan(app: FastAPI):
try:
async with app.state.db.sessions() as db:
app.state.snapshot = await load_settings(db)
await synchronize_source_tokens(db)
except Exception:
structlog.get_logger().warning("settings.warmup_failed")
settings_task = asyncio.create_task(refresh_settings_cache(app))
@@ -153,6 +156,7 @@ app = FastAPI(
redoc_url=None,
lifespan=lifespan,
)
app.include_router(notification_router)
log = structlog.get_logger()
@@ -243,7 +247,7 @@ async def request_context(request: Request, call_next: Any) -> Response:
response.headers["Access-Control-Allow-Headers"] = (
"Authorization,Content-Type,Idempotency-Key,X-Request-ID,X-Ux-Session-Id"
)
response.headers["Access-Control-Allow-Methods"] = "GET,POST,OPTIONS"
response.headers["Access-Control-Allow-Methods"] = "GET,POST,DELETE,OPTIONS"
response.headers["Vary"] = "Origin"
response.headers["X-Request-ID"] = request_id
response.headers["X-Content-Type-Options"] = "nosniff"
@@ -416,7 +420,7 @@ async def ready(request: Request, db: Session):
try:
await db.execute(text("SELECT 1"))
revision = await db.scalar(text("SELECT version_num FROM han_app.alembic_version LIMIT 1"))
if revision != "0005_otp_settings":
if revision != "0008_notifications_v1":
raise RuntimeError("unexpected database revision")
await load_settings(db)
components["postgres"] = "ok"
@@ -498,6 +502,14 @@ async def app_config(request: Request, response: Response, settings: SnapshotDep
"allowed_mime_types": settings.strings("chat.attachments.allowed_mime_types"),
"max_size_mb": settings.integer("chat.attachments.max_size_mb"),
},
"notification": {
"carousel_autoplay_enabled": settings.boolean(
"notification.carousel.autoplay_enabled"
),
"carousel_autoplay_interval_ms": settings.integer(
"notification.carousel.autoplay_interval_ms"
),
},
"ux": {"idle_timeout_minutes": settings.integer("ux.session.idle_timeout_minutes")},
}
@@ -1062,6 +1074,7 @@ async def realtime(websocket: WebSocket):
await websocket.close(code=4400)
return
ids = {uuid.UUID(value) for value in payload.get("dialog_ids", [])[:100]}
notifications = bool(payload.get("notifications", False))
count = await db.scalar(
select(func.count(Dialog.id)).where(
Dialog.id.in_(ids),
@@ -1076,10 +1089,16 @@ async def realtime(websocket: WebSocket):
event_task.cancel()
if event_stream:
await event_stream.aclose()
event_stream = websocket.app.state.realtime.events(ids)
event_stream = websocket.app.state.realtime.events(
ids, user.id, notifications
)
event_task = asyncio.create_task(anext(event_stream))
await websocket.send_json(
{"type": "subscribed", "dialog_ids": [str(value) for value in ids]}
{
"type": "subscribed",
"dialog_ids": [str(value) for value in ids],
"notifications": notifications,
}
)
except (AuthError, DomainError, ValueError):
await websocket.close(code=4401)
@@ -0,0 +1,298 @@
import secrets
import time
import uuid
from datetime import datetime
from decimal import Decimal
from typing import Any
from sqlalchemy import (
ARRAY,
BigInteger,
Boolean,
CheckConstraint,
DateTime,
ForeignKey,
Index,
Numeric,
SmallInteger,
String,
Text,
UniqueConstraint,
func,
)
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db import SCHEMA, Base, Common
def uuid7() -> uuid.UUID:
"""Generate an RFC 9562 UUIDv7 without relying on Python 3.14."""
timestamp_ms = int(time.time_ns() // 1_000_000) & ((1 << 48) - 1)
value = timestamp_ms << 80
value |= 0x7 << 76
value |= secrets.randbits(12) << 64
value |= 0b10 << 62
value |= secrets.randbits(62)
return uuid.UUID(int=value)
class NotificationCtaAction(Common, Base):
__tablename__ = "notification_cta_actions"
__table_args__ = (UniqueConstraint("code"), {"schema": SCHEMA})
code: Mapped[str] = mapped_column(String(32))
description: Mapped[str] = mapped_column(String(255))
requires_auth: Mapped[bool] = mapped_column(Boolean, default=False)
required_instance_fields: Mapped[list[str]] = mapped_column(
ARRAY(String(64)), default=list, server_default="{}"
)
class NotificationButton(Common, Base):
__tablename__ = "notification_buttons"
__table_args__ = (
UniqueConstraint("code"),
CheckConstraint("NOT applies_hidden_ttl OR sets_hidden"),
CheckConstraint("NOT submits_documents OR close_reason IS NOT NULL"),
{"schema": SCHEMA},
)
code: Mapped[str] = mapped_column(String(32))
label: Mapped[str] = mapped_column(String(64))
sets_hidden: Mapped[bool] = mapped_column(Boolean, default=False)
applies_hidden_ttl: Mapped[bool] = mapped_column(Boolean, default=False)
close_reason: Mapped[str | None] = mapped_column(String(32))
submits_documents: Mapped[bool] = mapped_column(Boolean, default=False)
class NotificationColorToken(Common, Base):
__tablename__ = "notification_color_tokens"
__table_args__ = (UniqueConstraint("code"), {"schema": SCHEMA})
code: Mapped[str] = mapped_column(String(32))
description: Mapped[str] = mapped_column(String(255))
sort_order: Mapped[int] = mapped_column(SmallInteger)
class NotificationType(Common, Base):
__tablename__ = "notification_types"
__table_args__ = (
UniqueConstraint("code"),
CheckConstraint("contour IN ('G','P')"),
CheckConstraint("contour <> 'G' OR countable = false"),
CheckConstraint("contour <> 'G' OR cta_action <> 'open_detail'"),
CheckConstraint(
"cta_action <> 'open_detail' OR (cta_sets_hidden = false AND cta_close_reason IS NULL)"
),
CheckConstraint(
"cta_action = 'open_detail' OR "
"(documents_allowed = false AND hide_on_document_download = false "
"AND required_detail_blocks = '{}')"
),
CheckConstraint("NOT hide_on_document_download OR documents_allowed"),
CheckConstraint("cta_action <> 'open_detail' OR button_primary_code IS NOT NULL"),
CheckConstraint(
"cta_action = 'open_detail' OR "
"(button_primary_code IS NULL AND button_secondary_code IS NULL)"
),
CheckConstraint("button_secondary_code IS NULL OR button_primary_code IS NOT NULL"),
CheckConstraint(
"button_secondary_code IS NULL OR button_secondary_code <> button_primary_code"
),
{"schema": SCHEMA},
)
code: Mapped[str] = mapped_column(String(32))
contour: Mapped[str] = mapped_column(String(1))
priority: Mapped[int] = mapped_column(SmallInteger)
countable: Mapped[bool] = mapped_column(Boolean)
label: Mapped[str] = mapped_column(String(64))
color_token: Mapped[str] = mapped_column(
ForeignKey(f"{SCHEMA}.notification_color_tokens.code", ondelete="RESTRICT")
)
icon_code: Mapped[str | None] = mapped_column(String(32))
cta_text: Mapped[str] = mapped_column(String(64))
cta_action: Mapped[str] = mapped_column(
ForeignKey(f"{SCHEMA}.notification_cta_actions.code", ondelete="RESTRICT")
)
cta_sets_hidden: Mapped[bool] = mapped_column(Boolean, default=False)
cta_close_reason: Mapped[str | None] = mapped_column(String(32))
button_primary_code: Mapped[str | None] = mapped_column(
ForeignKey(f"{SCHEMA}.notification_buttons.code", ondelete="RESTRICT")
)
button_secondary_code: Mapped[str | None] = mapped_column(
ForeignKey(f"{SCHEMA}.notification_buttons.code", ondelete="RESTRICT")
)
hidden_ttl_days: Mapped[int | None] = mapped_column(SmallInteger)
documents_allowed: Mapped[bool] = mapped_column(Boolean, default=False)
hide_on_document_download: Mapped[bool] = mapped_column(Boolean, default=False)
required_detail_blocks: Mapped[list[str]] = mapped_column(
ARRAY(String(64)), default=list, server_default="{}"
)
class NotificationSource(Common, Base):
__tablename__ = "notification_sources"
__table_args__ = (UniqueConstraint("code"), UniqueConstraint("token_hash"), {"schema": SCHEMA})
code: Mapped[str] = mapped_column(String(32))
description: Mapped[str | None] = mapped_column(Text)
token_hash: Mapped[str] = mapped_column(String(128))
token_rotated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
class Notification(Common, Base):
__tablename__ = "notifications"
__table_args__ = (
UniqueConstraint("source", "external_id", name="uq_notifications_source_key"),
CheckConstraint("lifecycle_status IN ('active','closed')"),
CheckConstraint("visibility IN ('visible','hidden')"),
CheckConstraint(
"close_reason IS NULL OR close_reason IN "
"('user_done','docs_submitted','offer_accepted','paid','expired','cancelled')"
),
CheckConstraint(
"lifecycle_status <> 'closed' OR (close_reason IS NOT NULL AND closed_at IS NOT NULL)"
),
CheckConstraint("old_price IS NULL OR price IS NOT NULL"),
Index(
"ix_notifications_user_active",
"user_id",
"lifecycle_status",
"visibility",
"notification_datetime",
"id",
),
Index("ix_notifications_expire", "date_expired"),
{"schema": SCHEMA},
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid7)
user_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey(f"{SCHEMA}.user_identities.id", ondelete="RESTRICT")
)
notification_type: Mapped[str] = mapped_column(
ForeignKey(f"{SCHEMA}.notification_types.code", ondelete="RESTRICT")
)
source: Mapped[str] = mapped_column(
ForeignKey(f"{SCHEMA}.notification_sources.code", ondelete="RESTRICT")
)
external_id: Mapped[str] = mapped_column(String(128))
request_fingerprint: Mapped[str] = mapped_column(String(64))
notification_datetime: Mapped[datetime] = mapped_column(DateTime(timezone=True))
header: Mapped[str] = mapped_column(String(255))
text: Mapped[str | None] = mapped_column(String(1024))
priority_override: Mapped[int | None] = mapped_column(SmallInteger)
date_expired: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
price: Mapped[Decimal | None] = mapped_column(Numeric(12, 2))
old_price: Mapped[Decimal | None] = mapped_column(Numeric(12, 2))
payment_url: Mapped[str | None] = mapped_column(Text)
details: Mapped[dict[str, Any] | None] = mapped_column(JSONB)
details_schema_version: Mapped[int] = mapped_column(SmallInteger, default=1)
chat_message_text: Mapped[str | None] = mapped_column(String(1024))
lifecycle_status: Mapped[str] = mapped_column(String(16), default="active")
visibility: Mapped[str] = mapped_column(String(16), default="visible")
is_read: Mapped[bool] = mapped_column(Boolean, default=False)
close_reason: Mapped[str | None] = mapped_column(String(32))
closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
class GuestNotification(Common, Base):
__tablename__ = "guest_notifications"
__table_args__ = (
CheckConstraint("lifecycle_status IN ('active','closed')"),
CheckConstraint("old_price IS NULL OR price IS NOT NULL"),
CheckConstraint("instruction_url IS NULL OR instruction_url LIKE 'https://%'"),
Index("ix_guest_notifications_active", "lifecycle_status", "notification_datetime", "id"),
Index("ix_guest_notifications_expire", "date_expired"),
{"schema": SCHEMA},
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid7)
notification_type: Mapped[str] = mapped_column(
ForeignKey(f"{SCHEMA}.notification_types.code", ondelete="RESTRICT")
)
notification_datetime: Mapped[datetime] = mapped_column(DateTime(timezone=True))
header: Mapped[str] = mapped_column(String(255))
text: Mapped[str | None] = mapped_column(String(1024))
priority_override: Mapped[int | None] = mapped_column(SmallInteger)
date_expired: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
price: Mapped[Decimal | None] = mapped_column(Numeric(12, 2))
old_price: Mapped[Decimal | None] = mapped_column(Numeric(12, 2))
instruction_url: Mapped[str | None] = mapped_column(Text)
chat_message_text: Mapped[str | None] = mapped_column(String(1024))
lifecycle_status: Mapped[str] = mapped_column(String(16), default="active")
closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
class NotificationDocument(Common, Base):
__tablename__ = "notification_documents"
__table_args__ = (
UniqueConstraint("notification_id", "document_id"),
{"schema": SCHEMA},
)
notification_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey(f"{SCHEMA}.notifications.id", ondelete="RESTRICT")
)
document_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey(f"{SCHEMA}.documents.id", ondelete="RESTRICT")
)
sort_order: Mapped[int] = mapped_column(SmallInteger, default=0)
download_url_issued_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
class ClientUploadDraft(Base):
__tablename__ = "client_upload_drafts"
__table_args__ = (
CheckConstraint("context_type IN ('notification')"),
CheckConstraint("size_bytes > 0"),
CheckConstraint("scan_status IN ('pending','clean','infected','failed')"),
CheckConstraint("state IN ('draft','submitted','discarded')"),
Index("ix_client_upload_drafts_context", "user_id", "context_type", "context_id"),
Index("ix_client_upload_drafts_scan", "scan_status", "updated_at"),
Index("ix_client_upload_drafts_created", "created_at"),
{"schema": SCHEMA},
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid7)
user_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey(f"{SCHEMA}.user_identities.id", ondelete="RESTRICT")
)
context_type: Mapped[str] = mapped_column(String(32))
context_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True))
original_file_name: Mapped[str] = mapped_column(String(255))
safe_file_name: Mapped[str] = mapped_column(String(255))
mime_type: Mapped[str] = mapped_column(String(128))
size_bytes: Mapped[int] = mapped_column(BigInteger)
checksum_sha256: Mapped[str | None] = mapped_column(String(64))
scan_status: Mapped[str] = mapped_column(String(16), default="pending")
storage_bucket: Mapped[str] = mapped_column(String(255))
object_key: Mapped[str] = mapped_column(String(1024))
quarantine_object_key: Mapped[str | None] = mapped_column(String(1024))
upload_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
state: Mapped[str] = mapped_column(String(16), default="draft")
submission_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class ClientDocument(Common, Base):
__tablename__ = "client_documents"
__table_args__ = (
UniqueConstraint("storage_bucket", "object_key"),
UniqueConstraint("source_draft_id"),
Index("ix_client_documents_context", "context_type", "context_id"),
Index("ix_client_documents_user_submitted", "user_id", "submitted_at"),
{"schema": SCHEMA},
)
user_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey(f"{SCHEMA}.user_identities.id", ondelete="RESTRICT")
)
context_type: Mapped[str] = mapped_column(String(32))
context_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True))
submission_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True))
source_draft_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True))
original_file_name: Mapped[str] = mapped_column(String(255))
safe_file_name: Mapped[str] = mapped_column(String(255))
mime_type: Mapped[str] = mapped_column(String(128))
size_bytes: Mapped[int] = mapped_column(BigInteger)
checksum_sha256: Mapped[str] = mapped_column(String(64))
storage_bucket: Mapped[str] = mapped_column(String(255))
object_key: Mapped[str] = mapped_column(String(1024))
submitted_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
@@ -0,0 +1,504 @@
import hashlib
import json
import uuid
from typing import Annotated
from fastapi import APIRouter, Depends, Header, Query, Request, Response
from fastapi.responses import JSONResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import AuthError
from app.db import UserIdentity
from app.notification_models import NotificationSource
from app.notification_schemas import (
NotificationCancelRequest,
NotificationCreateRequest,
UploadCompleteRequest,
UploadInitRequest,
)
from app.notification_service import (
apply_read_or_hide,
authenticate_source,
cancel_notification,
catalog,
complete_upload,
create_notification,
discard_upload,
document_download,
init_upload,
invoke_cta_state,
list_notifications,
list_uploads,
notification_dto,
owned_notification,
press_button,
public_notifications,
unread_count,
)
from app.schemas import TextMessageRequest
from app.services import (
AuditContext,
DomainError,
SettingsSnapshot,
create_dialog,
load_settings,
resolve_user,
send_message,
)
router = APIRouter()
async def db_session(request: Request):
async for value in request.app.state.db.session():
yield value
Session = Annotated[AsyncSession, Depends(db_session)]
async def user_dependency(
request: Request,
db: Session,
authorization: Annotated[str | None, Header()] = None,
) -> UserIdentity:
if not authorization or not authorization.startswith("Bearer "):
raise AuthError()
principal = await request.app.state.jwks.validate(authorization.removeprefix("Bearer ").strip())
return await resolve_user(db, principal)
User = Annotated[UserIdentity, Depends(user_dependency)]
async def snapshot_dependency(db: Session) -> SettingsSnapshot:
return await load_settings(db)
Snapshot = Annotated[SettingsSnapshot, Depends(snapshot_dependency)]
async def source_dependency(
db: Session, authorization: Annotated[str | None, Header()] = None
) -> NotificationSource:
return await authenticate_source(db, authorization)
Source = Annotated[NotificationSource, Depends(source_dependency)]
def context(request: Request, ux_session: str | None = None) -> AuditContext:
try:
ux_id = uuid.UUID(ux_session) if ux_session else None
except ValueError:
raise DomainError("validation_error", 400, "X-Ux-Session-Id must be UUID") from None
return AuditContext(
request_id=request.state.request_id,
trace_id=request.state.trace_id,
ux_session_id=ux_id,
user_agent_hash=request.state.user_agent_hash,
client_ip=None,
)
async def rate_limit(
request: Request,
identity: str,
route: str,
limit: tuple[int, int],
*,
fail_closed: bool,
) -> None:
key = request.app.state.rate_limiter.key("notification", identity, route, limit[1])
try:
retry_after = await request.app.state.rate_limiter.consume(key, *limit)
except Exception:
if fail_closed:
raise DomainError(
"dependency_unavailable", 503, "Rate limit service is unavailable"
) from None
return
if retry_after:
raise DomainError(
"rate_limit_exceeded",
429,
"Rate limit exceeded",
{"retry_after": retry_after},
)
@router.get("/api/v1/public/notifications", tags=["notifications"])
async def public_list(request: Request, db: Session, settings: Snapshot):
await rate_limit(
request,
request.client.host if request.client else "unknown",
"public",
settings.limit("rate_limit.notifications_public.per_ip"),
fail_closed=False,
)
return {
"items": await public_notifications(db, settings.integer("notification.home.max_items"))
}
@router.get("/api/v1/public/notification-types", tags=["notifications"])
async def type_catalog(
request: Request,
response: Response,
db: Session,
settings: Snapshot,
if_none_match: Annotated[str | None, Header()] = None,
):
await rate_limit(
request,
request.client.host if request.client else "unknown",
"public",
settings.limit("rate_limit.notifications_public.per_ip"),
fail_closed=False,
)
items = await catalog(db)
digest = hashlib.sha256(
json.dumps(items, default=str, sort_keys=True, separators=(",", ":")).encode()
).hexdigest()
etag = f'"{digest}"'
if if_none_match == etag:
return Response(status_code=304, headers={"ETag": etag})
response.headers["ETag"] = etag
response.headers["Cache-Control"] = "public, max-age=3600"
return {"items": items}
@router.get("/api/v1/notifications", tags=["notifications"])
async def personal_list(
request: Request,
db: Session,
user: User,
settings: Snapshot,
place: str = Query(pattern="^(home|center)$"),
):
await rate_limit(
request,
str(user.id),
"read",
settings.limit("rate_limit.notifications_read.per_user"),
fail_closed=False,
)
limit = settings.integer(
"notification.home.max_items" if place == "home" else "notification.center.max_items"
)
return {"items": await list_notifications(db, user.id, place, limit)}
@router.get("/api/v1/notifications/counter", tags=["notifications"])
async def counter(request: Request, db: Session, user: User, settings: Snapshot):
await rate_limit(
request,
str(user.id),
"read",
settings.limit("rate_limit.notifications_read.per_user"),
fail_closed=False,
)
return {
"unread_count": await unread_count(
db, user.id, settings.integer("notification.center.max_items")
)
}
@router.get("/api/v1/notifications/{notification_id}", tags=["notifications"])
async def detail(
notification_id: uuid.UUID,
request: Request,
db: Session,
user: User,
settings: Snapshot,
):
await rate_limit(
request,
str(user.id),
"read",
settings.limit("rate_limit.notifications_read.per_user"),
fail_closed=False,
)
item, kind = await owned_notification(db, user.id, notification_id)
if kind.cta_action != "open_detail":
raise DomainError("not_found", 404, "Resource was not found")
return await notification_dto(db, item, kind)
@router.post("/api/v1/notifications/{notification_id}/read", tags=["notifications"])
async def mark_read(
notification_id: uuid.UUID,
request: Request,
db: Session,
user: User,
settings: Snapshot,
x_ux_session_id: Annotated[str | None, Header(alias="X-Ux-Session-Id")] = None,
):
await rate_limit(
request,
str(user.id),
"action",
settings.limit("rate_limit.notifications_action.per_user"),
fail_closed=False,
)
return await apply_read_or_hide(
db,
user.id,
notification_id,
"read",
settings,
request.app.state.realtime,
context(request, x_ux_session_id),
)
@router.post("/api/v1/notifications/{notification_id}/hide", tags=["notifications"])
async def hide(
notification_id: uuid.UUID,
request: Request,
db: Session,
user: User,
settings: Snapshot,
x_ux_session_id: Annotated[str | None, Header(alias="X-Ux-Session-Id")] = None,
):
await rate_limit(
request,
str(user.id),
"action",
settings.limit("rate_limit.notifications_action.per_user"),
fail_closed=False,
)
return await apply_read_or_hide(
db,
user.id,
notification_id,
"hide",
settings,
request.app.state.realtime,
context(request, x_ux_session_id),
)
@router.post(
"/api/v1/notifications/{notification_id}/buttons/{button_code}",
tags=["notifications"],
)
async def button(
notification_id: uuid.UUID,
button_code: str,
request: Request,
db: Session,
user: User,
settings: Snapshot,
x_ux_session_id: Annotated[str | None, Header(alias="X-Ux-Session-Id")] = None,
):
await rate_limit(
request,
str(user.id),
"action",
settings.limit("rate_limit.notifications_action.per_user"),
fail_closed=False,
)
return await press_button(
db,
user.id,
notification_id,
button_code,
settings,
request.app.state.realtime,
context(request, x_ux_session_id),
)
@router.post("/api/v1/notifications/{notification_id}/cta", tags=["notifications"])
async def cta(
notification_id: uuid.UUID,
request: Request,
db: Session,
user: User,
settings: Snapshot,
x_ux_session_id: Annotated[str | None, Header(alias="X-Ux-Session-Id")] = None,
):
await rate_limit(
request,
str(user.id),
"action",
settings.limit("rate_limit.notifications_action.per_user"),
fail_closed=False,
)
audit_context = context(request, x_ux_session_id)
item, kind = await owned_notification(db, user.id, notification_id, action=True)
chat_result = None
if kind.cta_action == "send_chat_message":
dialog, _status = await create_dialog(
db, user, audit_context, f"notification-dialog:{item.id}"
)
chat_result = await send_message(
db,
user,
uuid.UUID(str(dialog["dialog_id"])),
TextMessageRequest(content_kind="text", text=item.chat_message_text or ""),
f"notification-message:{item.id}",
audit_context,
request.app.state.settings,
request.app.state.safety,
request.app.state.openlines,
request.app.state.s3,
request.app.state.realtime,
)
_item, _kind, result = await invoke_cta_state(
db,
user.id,
notification_id,
settings,
request.app.state.realtime,
audit_context,
chat_result=chat_result,
)
return result
@router.get(
"/api/v1/notifications/{notification_id}/documents/{document_id}/download-url",
tags=["notifications"],
)
async def download(
notification_id: uuid.UUID,
document_id: uuid.UUID,
request: Request,
db: Session,
user: User,
settings: Snapshot,
x_ux_session_id: Annotated[str | None, Header(alias="X-Ux-Session-Id")] = None,
):
await rate_limit(
request,
str(user.id),
"download",
settings.limit("rate_limit.download_url.per_user"),
fail_closed=True,
)
return await document_download(
db,
user.id,
notification_id,
document_id,
settings,
request.app.state.s3,
request.app.state.realtime,
context(request, x_ux_session_id),
)
@router.post("/api/v1/uploads/init", status_code=201, tags=["uploads"])
async def upload_init(
body: UploadInitRequest,
request: Request,
db: Session,
user: User,
settings: Snapshot,
):
await rate_limit(
request,
str(user.id),
"upload",
settings.limit("rate_limit.notification_upload.per_user"),
fail_closed=True,
)
return await init_upload(db, user.id, body, settings, request.app.state.s3)
@router.post("/api/v1/uploads/{draft_id}/complete", tags=["uploads"])
async def upload_complete(
draft_id: uuid.UUID,
body: UploadCompleteRequest,
request: Request,
db: Session,
user: User,
settings: Snapshot,
):
await rate_limit(
request,
str(user.id),
"upload",
settings.limit("rate_limit.notification_upload.per_user"),
fail_closed=True,
)
return await complete_upload(
db,
user.id,
draft_id,
body,
request.app.state.s3,
request.app.state.safety,
request.state.request_id,
)
@router.get("/api/v1/uploads", tags=["uploads"])
async def uploads(
request: Request,
db: Session,
user: User,
settings: Snapshot,
context_type: str,
context_id: uuid.UUID,
):
await rate_limit(
request,
str(user.id),
"upload",
settings.limit("rate_limit.notification_upload.per_user"),
fail_closed=True,
)
if context_type != "notification":
raise DomainError("validation_error", 400, "Unsupported upload context")
return {"items": await list_uploads(db, user.id, context_type, context_id)}
@router.delete("/api/v1/uploads/{draft_id}", status_code=204, tags=["uploads"])
async def upload_delete(
draft_id: uuid.UUID,
request: Request,
db: Session,
user: User,
settings: Snapshot,
):
await rate_limit(
request,
str(user.id),
"upload",
settings.limit("rate_limit.notification_upload.per_user"),
fail_closed=True,
)
await discard_upload(db, user.id, draft_id, request.app.state.s3)
return Response(status_code=204)
@router.post("/internal/notifications/v1/notifications", tags=["internal"])
async def internal_create(
body: NotificationCreateRequest,
request: Request,
db: Session,
source: Source,
):
result, status = await create_notification(
db,
body,
source,
request.app.state.s3,
request.app.state.realtime,
context(request),
)
return JSONResponse(json.loads(json.dumps(result, default=str)), status_code=status)
@router.post("/internal/notifications/v1/notifications/cancel", tags=["internal"])
async def internal_cancel(
body: NotificationCancelRequest,
request: Request,
db: Session,
source: Source,
):
return await cancel_notification(db, body, source, request.app.state.realtime, context(request))
@@ -0,0 +1,72 @@
import uuid
from datetime import datetime
from decimal import Decimal
from typing import Literal
from pydantic import Field, HttpUrl, model_validator
from app.schemas import StrictModel
class TodoItem(StrictModel):
number: int
text: str = Field(min_length=1, max_length=1024)
class CompanyDocumentInput(StrictModel):
object_key: str = Field(min_length=1, max_length=1024)
title: str = Field(min_length=1, max_length=255)
mime_type: str = Field(min_length=1, max_length=128)
size_bytes: int = Field(gt=0)
checksum_sha256: str = Field(pattern=r"^[0-9a-f]{64}$")
class NotificationDetailsInput(StrictModel):
deadline: datetime | None = None
details_header: str | None = Field(default=None, max_length=255)
details_text: str | None = Field(default=None, max_length=4000)
todo_header: str | None = Field(default=None, max_length=255)
todo_plan: list[TodoItem] | None = Field(default=None, min_length=1)
send_documents: bool = False
documents: list[CompanyDocumentInput] | None = Field(default=None, min_length=1)
class NotificationCreateRequest(StrictModel):
user_id: uuid.UUID
notification_type: str = Field(min_length=1, max_length=32)
source: str = Field(min_length=1, max_length=32)
external_id: str = Field(min_length=1, max_length=128)
notification_datetime: datetime
header: str = Field(min_length=1, max_length=255)
text: str | None = Field(default=None, max_length=1024)
priority_override: int | None = None
date_expired: datetime | None = None
price: Decimal | None = Field(default=None, ge=0, max_digits=12, decimal_places=2)
old_price: Decimal | None = Field(default=None, ge=0, max_digits=12, decimal_places=2)
payment_url: HttpUrl | None = None
chat_message_text: str | None = Field(default=None, max_length=1024)
details: NotificationDetailsInput | None = None
@model_validator(mode="after")
def prices(self) -> "NotificationCreateRequest":
if self.old_price is not None and self.price is None:
raise ValueError("old_price requires price")
return self
class NotificationCancelRequest(StrictModel):
source: str = Field(min_length=1, max_length=32)
external_id: str = Field(min_length=1, max_length=128)
close_reason: Literal["cancelled", "paid"]
class UploadInitRequest(StrictModel):
context_type: Literal["notification"]
context_id: uuid.UUID
file_name: str = Field(min_length=1, max_length=255)
mime_type: str = Field(min_length=1, max_length=128)
size_bytes: int = Field(gt=0)
class UploadCompleteRequest(StrictModel):
checksum: str = Field(pattern=r"^sha256:[0-9a-f]{64}$")
File diff suppressed because it is too large Load Diff
+40 -7
View File
@@ -7,7 +7,10 @@ from typing import Any
from redis.asyncio import Redis
CHANNEL_PREFIX = "han:rt:dialog:"
DIALOG_CHANNEL_PREFIX = "han:rt:dialog:"
USER_CHANNEL_PREFIX = "han:rt:user:"
# Backward-compatible name used by existing chat integrations.
CHANNEL_PREFIX = DIALOG_CHANNEL_PREFIX
class LocalFanout:
@@ -36,28 +39,58 @@ class RealtimeFanout:
async def publish(self, event: dict[str, Any]) -> None:
event = {"event_id": str(uuid.uuid4()), **event}
channel = CHANNEL_PREFIX + str(event["dialog_id"])
channel = DIALOG_CHANNEL_PREFIX + str(event["dialog_id"])
try:
await self.redis.publish(channel, json.dumps(event, default=str, separators=(",", ":")))
except Exception:
await self.local.publish(event)
async def events(self, dialog_ids: set[uuid.UUID]) -> AsyncIterator[dict[str, Any]]:
channels = [CHANNEL_PREFIX + str(dialog_id) for dialog_id in dialog_ids]
async def publish_user(self, user_id: uuid.UUID, event: dict[str, Any]) -> None:
event = {"event_id": str(uuid.uuid4()), "_user_id": str(user_id), **event}
channel = USER_CHANNEL_PREFIX + str(user_id)
try:
await self.redis.publish(channel, json.dumps(event, default=str, separators=(",", ":")))
except Exception:
await self.local.publish(event)
async def events(
self,
dialog_ids: set[uuid.UUID],
user_id: uuid.UUID | None = None,
notifications: bool = False,
) -> AsyncIterator[dict[str, Any]]:
channels = [DIALOG_CHANNEL_PREFIX + str(dialog_id) for dialog_id in dialog_ids]
if notifications and user_id is not None:
channels.append(USER_CHANNEL_PREFIX + str(user_id))
if not channels:
await asyncio.Event().wait()
return
pubsub = self.redis.pubsub()
try:
await pubsub.subscribe(*channels)
except Exception:
await pubsub.aclose()
async for event in self.local.subscribe():
if uuid.UUID(str(event["dialog_id"])) in dialog_ids:
yield event
dialog_match = event.get("dialog_id") and uuid.UUID(
str(event["dialog_id"])
) in dialog_ids
user_match = (
notifications
and user_id is not None
and event.get("_user_id") == str(user_id)
)
if dialog_match or user_match:
payload = dict(event)
payload.pop("_user_id", None)
yield payload
return
try:
while True:
message = await pubsub.get_message(ignore_subscribe_messages=True, timeout=1)
if message:
yield json.loads(message["data"])
payload = json.loads(message["data"])
payload.pop("_user_id", None)
yield payload
else:
await asyncio.sleep(0)
finally:
@@ -87,6 +87,19 @@ REQUIRED_SETTINGS = {
"ux.session.idle_timeout_minutes",
"security.cors.allowed_origins",
"security.public_cache.max_age_seconds",
"notification.home.max_items",
"notification.center.max_items",
"notification.carousel.autoplay_enabled",
"notification.carousel.autoplay_interval_ms",
"notification.hidden.default_ttl_days",
"notification.documents.max_files",
"notification.instruction.allowed_hosts",
"notification.expire_job.run_at",
"notification.upload_draft.ttl_days",
"rate_limit.notifications_read.per_user",
"rate_limit.notifications_action.per_user",
"rate_limit.notification_upload.per_user",
"rate_limit.notifications_public.per_ip",
} | OTP_SETTING_KEYS
@@ -67,6 +67,9 @@ class Settings(BaseSettings):
cursor_hmac_secret: SecretStr = Field(alias="CURSOR_HMAC_SECRET")
trusted_proxy_cidrs: str = Field(default="127.0.0.1/32", alias="TRUSTED_PROXY_CIDRS")
worker_poll_interval_sec: float = Field(default=2, alias="WORKER_POLL_INTERVAL_SEC")
notifications_token_producer_test: SecretStr | None = Field(
default=None, alias="NOTIFICATIONS_TOKEN_PRODUCER_TEST"
)
@property
def issuer(self) -> str:
+81 -2
View File
@@ -5,7 +5,7 @@ from datetime import UTC, datetime, timedelta
import httpx
import redis.asyncio as redis
import structlog
from sqlalchemy import select
from sqlalchemy import delete, select
from app.db import Database, DeliveryOutbox, Dialog, Message, MessageAttachment, SafetyTask
from app.integrations import (
@@ -15,8 +15,10 @@ from app.integrations import (
SafetyClient,
fresh_openlines_payload,
)
from app.notification_models import ClientUploadDraft
from app.notification_service import expire_notifications
from app.realtime import RealtimeFanout
from app.services import publish_dialog_status, publish_message_status
from app.services import load_settings, publish_dialog_status, publish_message_status
from app.settings import Settings, get_settings
log = structlog.get_logger()
@@ -222,6 +224,75 @@ async def loop(kind: str) -> None:
await db.close()
async def notification_expire_loop() -> None:
settings = get_settings()
db = Database(settings.database_url)
try:
while True:
async with db.sessions() as session:
snapshot = await load_settings(session)
run_at = snapshot.values["notification.expire_job.run_at"]
hour, minute = (int(value) for value in run_at.split(":", 1))
now = datetime.now(UTC)
target = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
if target <= now:
target += timedelta(days=1)
await asyncio.sleep((target - now).total_seconds())
async with db.sessions() as session:
personal, guest = await expire_notifications(session)
log.info(
"notification.expired_batch",
personal_count=personal,
guest_count=guest,
)
finally:
await db.close()
async def notification_draft_cleanup_once(db: Database, s3: S3Client) -> int:
async with db.sessions() as session:
snapshot = await load_settings(session)
cutoff = datetime.now(UTC) - timedelta(
days=snapshot.integer("notification.upload_draft.ttl_days")
)
rows = (
(
await session.execute(
select(ClientUploadDraft)
.where(ClientUploadDraft.created_at < cutoff)
.with_for_update(skip_locked=True)
.limit(100)
)
)
.scalars()
.all()
)
for row in rows:
if row.state != "submitted":
if row.quarantine_object_key:
await s3.delete_quarantine(row.quarantine_object_key)
elif row.object_key:
await s3.delete(row.storage_bucket, row.object_key)
await session.execute(
delete(ClientUploadDraft).where(ClientUploadDraft.id == row.id)
)
await session.commit()
return len(rows)
async def notification_draft_cleanup_loop() -> None:
settings = get_settings()
db = Database(settings.database_url)
s3 = S3Client(settings)
try:
while True:
count = await notification_draft_cleanup_once(db, s3)
if count < 100:
await asyncio.sleep(86400)
finally:
await db.close()
def delivery_main() -> None:
asyncio.run(loop("delivery"))
@@ -232,3 +303,11 @@ def safety_main() -> None:
def cleanup_main() -> None:
asyncio.run(loop("cleanup"))
def notification_expire_main() -> None:
asyncio.run(notification_expire_loop())
def notification_draft_cleanup_main() -> None:
asyncio.run(notification_draft_cleanup_loop())
@@ -37,6 +37,7 @@ services:
SELECTEL_S3_SECRET_KEY: ${SELECTEL_S3_SECRET_KEY}
OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT}
CURSOR_HMAC_SECRET: ${CURSOR_HMAC_SECRET}
NOTIFICATIONS_TOKEN_PRODUCER_TEST: ${NOTIFICATIONS_TOKEN_PRODUCER_TEST}
healthcheck:
test:
- CMD
+236
View File
@@ -179,6 +179,168 @@ paths:
security: [{bearerAuth: []}]
responses:
"200": {description: Audited short-lived presigned GET}
/api/v1/public/notifications:
get:
operationId: listGuestNotifications
responses:
"200":
description: Server-sorted active guest campaigns
content:
application/json:
schema: {$ref: "#/components/schemas/NotificationList"}
/api/v1/public/notification-types:
get:
operationId: getNotificationCatalog
parameters:
- {name: If-None-Match, in: header, schema: {type: string}}
responses:
"200": {description: Public catalog with ETag}
"304": {description: Catalog is unchanged}
/api/v1/notifications:
get:
operationId: listNotifications
security: [{bearerAuth: []}]
parameters:
- {name: place, in: query, required: true, schema: {type: string, enum: [home, center]}}
responses:
"200":
description: Server-sorted personal notifications
content:
application/json:
schema: {$ref: "#/components/schemas/NotificationList"}
/api/v1/notifications/counter:
get:
operationId: getNotificationCounter
security: [{bearerAuth: []}]
responses:
"200":
description: Unread count in the center window
content:
application/json:
schema: {$ref: "#/components/schemas/NotificationCounter"}
/api/v1/notifications/{notification_id}:
parameters:
- {$ref: "#/components/parameters/NotificationId"}
get:
operationId: getNotification
security: [{bearerAuth: []}]
responses:
"200": {description: Active notification detail}
"404": {$ref: "#/components/responses/NotFound"}
/api/v1/notifications/{notification_id}/read:
parameters:
- {$ref: "#/components/parameters/NotificationId"}
post:
operationId: readNotification
security: [{bearerAuth: []}]
responses:
"200": {description: Current notification state}
"409": {$ref: "#/components/responses/Conflict"}
/api/v1/notifications/{notification_id}/hide:
parameters:
- {$ref: "#/components/parameters/NotificationId"}
post:
operationId: hideNotification
security: [{bearerAuth: []}]
responses:
"200": {description: Current notification state}
"409": {$ref: "#/components/responses/Conflict"}
/api/v1/notifications/{notification_id}/buttons/{button_code}:
parameters:
- {$ref: "#/components/parameters/NotificationId"}
- {name: button_code, in: path, required: true, schema: {type: string}}
post:
operationId: pressNotificationButton
security: [{bearerAuth: []}]
responses:
"200": {description: Current notification state}
"409": {$ref: "#/components/responses/Conflict"}
"422": {$ref: "#/components/responses/Unprocessable"}
/api/v1/notifications/{notification_id}/cta:
parameters:
- {$ref: "#/components/parameters/NotificationId"}
post:
operationId: invokeNotificationCta
security: [{bearerAuth: []}]
responses:
"200": {description: CTA result and current state}
"409": {$ref: "#/components/responses/Conflict"}
/api/v1/notifications/{notification_id}/documents/{document_id}/download-url:
parameters:
- {$ref: "#/components/parameters/NotificationId"}
- {$ref: "#/components/parameters/DocumentId"}
get:
operationId: getNotificationDocumentDownloadUrl
security: [{bearerAuth: []}]
responses:
"200": {description: Audited short-lived presigned GET}
"404": {$ref: "#/components/responses/NotFound"}
/api/v1/uploads/init:
post:
operationId: initClientUpload
security: [{bearerAuth: []}]
requestBody:
required: true
content:
application/json:
schema: {$ref: "#/components/schemas/UploadInitRequest"}
responses:
"201": {description: Upload draft and presigned PUT}
/api/v1/uploads/{draft_id}/complete:
parameters:
- {$ref: "#/components/parameters/DraftId"}
post:
operationId: completeClientUpload
security: [{bearerAuth: []}]
requestBody:
required: true
content:
application/json:
schema: {$ref: "#/components/schemas/ChecksumRequest"}
responses:
"200": {description: Current scan state}
/api/v1/uploads:
get:
operationId: listClientUploads
security: [{bearerAuth: []}]
parameters:
- {name: context_type, in: query, required: true, schema: {type: string, enum: [notification]}}
- {name: context_id, in: query, required: true, schema: {type: string, format: uuid}}
responses:
"200": {description: Upload drafts for context}
/api/v1/uploads/{draft_id}:
parameters:
- {$ref: "#/components/parameters/DraftId"}
delete:
operationId: deleteClientUpload
security: [{bearerAuth: []}]
responses:
"204": {description: Draft discarded}
/internal/notifications/v1/notifications:
post:
operationId: createNotification
security: [{serviceBearer: []}]
requestBody:
required: true
content:
application/json:
schema: {$ref: "#/components/schemas/NotificationCreateRequest"}
responses:
"200": {description: Idempotent duplicate}
"201": {description: Notification created}
"409": {$ref: "#/components/responses/Conflict"}
/internal/notifications/v1/notifications/cancel:
post:
operationId: cancelNotification
security: [{serviceBearer: []}]
requestBody:
required: true
content:
application/json:
schema: {$ref: "#/components/schemas/NotificationCancelRequest"}
responses:
"200": {description: Notification closed or already closed}
"404": {$ref: "#/components/responses/NotFound"}
/internal/openlines/v1/inbox:
post:
operationId: applyOpenLinesInbox
@@ -211,6 +373,8 @@ components:
DialogId: {name: dialog_id, in: path, required: true, schema: {type: string, format: uuid}}
AttachmentId: {name: attachment_id, in: path, required: true, schema: {type: string, format: uuid}}
DocumentId: {name: document_id, in: path, required: true, schema: {type: string, format: uuid}}
NotificationId: {name: notification_id, in: path, required: true, schema: {type: string, format: uuid}}
DraftId: {name: draft_id, in: path, required: true, schema: {type: string, format: uuid}}
IdempotencyKey: {name: Idempotency-Key, in: header, required: true, schema: {type: string, minLength: 1, maxLength: 128}}
responses:
Unauthorized:
@@ -222,6 +386,12 @@ components:
DependencyUnavailable:
description: Required dependency is unavailable
content: {application/json: {schema: {$ref: "#/components/schemas/ErrorEnvelope"}}}
Conflict:
description: Notification state or idempotency conflict
content: {application/json: {schema: {$ref: "#/components/schemas/ErrorEnvelope"}}}
Unprocessable:
description: Catalog action is not allowed
content: {application/json: {schema: {$ref: "#/components/schemas/ErrorEnvelope"}}}
schemas:
OtpSettingsResponse:
type: object
@@ -256,6 +426,72 @@ components:
message: {type: string}
request_id: {type: string}
details: {type: object}
NotificationCounter:
type: object
additionalProperties: false
required: [unread_count]
properties:
unread_count: {type: integer, minimum: 0}
NotificationList:
type: object
additionalProperties: false
required: [items]
properties:
items:
type: array
items: {$ref: "#/components/schemas/Notification"}
Notification:
type: object
required: [id, notification_type, notification_datetime, header]
properties:
id: {type: string, format: uuid}
notification_type: {type: string}
notification_datetime: {type: string, format: date-time}
header: {type: string}
text: {type: [string, "null"]}
instruction_url: {type: [string, "null"], format: uri}
instruction_open_mode: {type: [string, "null"], enum: [new_tab, null]}
lifecycle_status: {type: string, enum: [active, closed]}
visibility: {type: string, enum: [visible, hidden]}
is_read: {type: boolean}
details: {type: [object, "null"]}
NotificationCreateRequest:
type: object
additionalProperties: false
required: [user_id, notification_type, source, external_id, notification_datetime, header]
properties:
user_id: {type: string, format: uuid}
notification_type: {type: string, maxLength: 32}
source: {type: string, maxLength: 32}
external_id: {type: string, maxLength: 128}
notification_datetime: {type: string, format: date-time}
header: {type: string, maxLength: 255}
text: {type: [string, "null"], maxLength: 1024}
priority_override: {type: [integer, "null"]}
date_expired: {type: [string, "null"], format: date-time}
price: {type: [number, "null"]}
old_price: {type: [number, "null"]}
payment_url: {type: [string, "null"], format: uri}
chat_message_text: {type: [string, "null"], maxLength: 1024}
details: {type: [object, "null"]}
NotificationCancelRequest:
type: object
additionalProperties: false
required: [source, external_id, close_reason]
properties:
source: {type: string}
external_id: {type: string}
close_reason: {type: string, enum: [cancelled, paid]}
UploadInitRequest:
type: object
additionalProperties: false
required: [context_type, context_id, file_name, mime_type, size_bytes]
properties:
context_type: {const: notification}
context_id: {type: string, format: uuid}
file_name: {type: string, maxLength: 255}
mime_type: {type: string, maxLength: 128}
size_bytes: {type: integer, minimum: 1}
ConsentChoice:
type: object
additionalProperties: false
@@ -36,6 +36,8 @@ han-api = "app.main:run"
han-delivery-worker = "app.workers:delivery_main"
han-safety-worker = "app.workers:safety_main"
han-cleanup-worker = "app.workers:cleanup_main"
han-notification-expire-worker = "app.workers:notification_expire_main"
han-notification-draft-cleanup-worker = "app.workers:notification_draft_cleanup_main"
[build-system]
requires = ["hatchling"]
@@ -14,11 +14,25 @@ EXPECTED_PATHS = {
"/health/ready",
"/api/v1/public/app-config",
"/api/v1/public/content",
"/api/v1/public/notifications",
"/api/v1/public/notification-types",
"/api/v1/auth/bootstrap",
"/api/v1/consents",
"/api/v1/analytics/session-start",
"/api/v1/me",
"/api/v1/me/documents",
"/api/v1/notifications",
"/api/v1/notifications/counter",
"/api/v1/notifications/{notification_id}",
"/api/v1/notifications/{notification_id}/read",
"/api/v1/notifications/{notification_id}/hide",
"/api/v1/notifications/{notification_id}/buttons/{button_code}",
"/api/v1/notifications/{notification_id}/cta",
"/api/v1/notifications/{notification_id}/documents/{document_id}/download-url",
"/api/v1/uploads/init",
"/api/v1/uploads/{draft_id}/complete",
"/api/v1/uploads",
"/api/v1/uploads/{draft_id}",
"/api/v1/documents/{document_id}",
"/api/v1/documents/{document_id}/download-url",
"/api/v1/dialogs",
@@ -28,6 +42,8 @@ EXPECTED_PATHS = {
"/api/v1/dialogs/{dialog_id}/attachments/{attachment_id}/complete",
"/api/v1/dialogs/{dialog_id}/attachments/{attachment_id}/download-url",
"/internal/openlines/v1/inbox",
"/internal/notifications/v1/notifications",
"/internal/notifications/v1/notifications/cancel",
"/internal/settings/v1/otp",
}
@@ -46,6 +62,33 @@ def test_websocket_route_is_registered() -> None:
assert any(getattr(route, "path", None) == "/api/v1/realtime" for route in app.routes)
def test_notification_http_methods_match_contract() -> None:
paths = app.openapi()["paths"]
expected = {
"/api/v1/public/notifications": {"get"},
"/api/v1/public/notification-types": {"get"},
"/api/v1/notifications": {"get"},
"/api/v1/notifications/counter": {"get"},
"/api/v1/notifications/{notification_id}": {"get"},
"/api/v1/notifications/{notification_id}/read": {"post"},
"/api/v1/notifications/{notification_id}/hide": {"post"},
"/api/v1/notifications/{notification_id}/buttons/{button_code}": {"post"},
"/api/v1/notifications/{notification_id}/cta": {"post"},
(
"/api/v1/notifications/{notification_id}/documents/"
"{document_id}/download-url"
): {"get"},
"/api/v1/uploads/init": {"post"},
"/api/v1/uploads/{draft_id}/complete": {"post"},
"/api/v1/uploads": {"get"},
"/api/v1/uploads/{draft_id}": {"delete"},
"/internal/notifications/v1/notifications": {"post"},
"/internal/notifications/v1/notifications/cancel": {"post"},
}
for path, methods in expected.items():
assert methods <= paths[path].keys()
def test_websocket_accepts_canonical_base64url_jwt_protocol() -> None:
jwt = "header.payload.signature"
encoded = base64.urlsafe_b64encode(jwt.encode()).decode().rstrip("=")
@@ -0,0 +1,589 @@
import ast
import json
import uuid
from collections.abc import AsyncGenerator
from datetime import UTC, datetime, timedelta
from pathlib import Path
from types import SimpleNamespace
from typing import Any, cast
from unittest.mock import AsyncMock, Mock
import pytest
from pydantic import ValidationError
from app.db import Document
from app.notification_models import (
ClientUploadDraft,
Notification,
NotificationButton,
NotificationDocument,
NotificationSource,
NotificationType,
uuid7,
)
from app.notification_schemas import NotificationCreateRequest
from app.notification_service import (
_apply_hidden_ttl,
apply_read_or_hide,
authenticate_source,
catalog,
create_notification,
document_download,
expire_notifications,
fingerprint,
invoke_cta_state,
press_button,
source_token_hash,
validate_create,
)
from app.realtime import USER_CHANNEL_PREFIX, RealtimeFanout
from app.services import AuditContext, DomainError, SettingsSnapshot
from app.workers import notification_draft_cleanup_once
def test_notification_migration_does_not_prepare_multiple_sql_commands() -> None:
migration = Path("alembic/versions/0008_notification_center_v1.py")
tree = ast.parse(migration.read_text(encoding="utf-8"))
for call in (node for node in ast.walk(tree) if isinstance(node, ast.Call)):
if (
isinstance(call.func, ast.Attribute)
and call.func.attr == "execute"
and call.args
and isinstance(call.args[0], ast.Call)
and isinstance(call.args[0].func, ast.Attribute)
and call.args[0].func.attr == "text"
and call.args[0].args
and isinstance(call.args[0].args[0], ast.Constant)
and isinstance(call.args[0].args[0].value, str)
):
assert call.args[0].args[0].value.count(";") <= 1
def create_body(**changes: object) -> NotificationCreateRequest:
values: dict[str, object] = {
"user_id": uuid.uuid4(),
"notification_type": "news",
"source": "producer_test",
"external_id": "event-1",
"notification_datetime": "2026-07-27T12:00:00Z",
"header": "Новость",
"details": {"details_text": "Текст"},
}
values.update(changes)
return NotificationCreateRequest.model_validate(values)
def context() -> AuditContext:
return AuditContext("request-1", "trace-1", None, None, None)
def snapshot(default_ttl: int = 3) -> SettingsSnapshot:
return SettingsSnapshot(
{
"notification.hidden.default_ttl_days": str(default_ttl),
"notification.center.max_items": "15",
},
"v1",
)
def notification(**changes: object) -> Notification:
values: dict[str, object] = {
"id": uuid.uuid4(),
"user_id": uuid.uuid4(),
"notification_type": "news",
"source": "producer_test",
"external_id": "event-1",
"request_fingerprint": "a" * 64,
"notification_datetime": datetime.now(UTC),
"header": "Header",
"lifecycle_status": "active",
"visibility": "visible",
"is_read": False,
"date_expired": None,
"close_reason": None,
"closed_at": None,
}
values.update(changes)
return Notification(**values)
def kind(**changes: object) -> NotificationType:
values: dict[str, object] = {
"id": uuid.uuid4(),
"code": "news",
"contour": "P",
"priority": 4,
"countable": True,
"label": "Новость",
"color_token": "info",
"icon_code": "news",
"cta_text": "Подробнее",
"cta_action": "open_detail",
"cta_sets_hidden": False,
"cta_close_reason": None,
"button_primary_code": "gotit",
"button_secondary_code": None,
"hidden_ttl_days": None,
"documents_allowed": False,
"hide_on_document_download": False,
"required_detail_blocks": [],
}
values.update(changes)
return NotificationType(**values)
class ScalarRows:
def __init__(self, rows: list[object]) -> None:
self.rows = rows
def scalars(self) -> "ScalarRows":
return self
def all(self) -> list[object]:
return self.rows
def test_uuid7_has_rfc_version_variant_and_embedded_timestamp(
monkeypatch: pytest.MonkeyPatch,
) -> None:
timestamp_ns = 1_722_340_800_123_000_000
monkeypatch.setattr("app.notification_models.time.time_ns", lambda: timestamp_ns)
monkeypatch.setattr("app.notification_models.secrets.randbits", lambda bits: (1 << bits) - 1)
value = uuid7()
assert value.version == 7
assert value.variant == uuid.RFC_4122
assert value.int >> 80 == timestamp_ns // 1_000_000
def test_fingerprint_is_canonical_stable_and_sensitive_to_body() -> None:
first = create_body()
same = NotificationCreateRequest.model_validate(first.model_dump(mode="json"))
changed = create_body(header="Другая новость")
assert fingerprint(first) == fingerprint(same)
assert fingerprint(first) != fingerprint(changed)
assert len(fingerprint(first)) == 64
def test_create_schema_rejects_read_only_or_unknown_detail_blocks() -> None:
with pytest.raises(ValidationError) as pending:
create_body(details={"details_text": "Text", "pending_documents": []})
with pytest.raises(ValidationError) as unknown:
create_body(details={"details_text": "Text", "invented": True})
assert "pending_documents" in str(pending.value)
assert "invented" in str(unknown.value)
def test_catalog_driven_create_validation_covers_required_and_forbidden_fields() -> None:
action = SimpleNamespace(required_instance_fields=["details"])
docs_kind = kind(
required_detail_blocks=["documents"],
documents_allowed=True,
button_primary_code="gotit",
)
with pytest.raises(DomainError) as error:
validate_create(create_body(details={"details_text": "No documents"}), docs_kind, action)
assert error.value.code == "validation_error"
assert error.value.details["fields"] == ["details.documents"]
with pytest.raises(DomainError) as forbidden:
validate_create(
create_body(
details={"details_text": "Text"},
payment_url="https://pay.example/order",
),
kind(),
action,
)
assert forbidden.value.details["fields"] == ["payment_url"]
@pytest.mark.asyncio
async def test_duplicate_create_returns_existing_only_for_matching_fingerprint(
monkeypatch: pytest.MonkeyPatch,
) -> None:
body = create_body()
existing = notification(request_fingerprint=fingerprint(body))
session = SimpleNamespace(
scalar=AsyncMock(return_value=existing),
add=Mock(),
commit=AsyncMock(),
)
dto = {"id": str(existing.id)}
monkeypatch.setattr(
"app.notification_service.notification_dto", AsyncMock(return_value=dto)
)
result, status = await create_notification(
session,
body,
SimpleNamespace(code="producer_test"),
SimpleNamespace(),
SimpleNamespace(),
context(),
)
assert (result, status) == (dto, 200)
existing.request_fingerprint = "0" * 64
with pytest.raises(DomainError) as conflict:
await create_notification(
session,
body,
SimpleNamespace(code="producer_test"),
SimpleNamespace(),
SimpleNamespace(),
context(),
)
assert conflict.value.code == "notification_conflict"
assert conflict.value.status == 409
assert conflict.value.details == {"notification_id": str(existing.id)}
def test_hidden_ttl_preserves_existing_expiry_and_uses_type_or_default(
monkeypatch: pytest.MonkeyPatch,
) -> None:
now = datetime(2026, 7, 27, 12, tzinfo=UTC)
monkeypatch.setattr("app.notification_service.datetime", SimpleNamespace(now=lambda tz: now))
existing = now + timedelta(hours=2)
with_expiry = notification(date_expired=existing)
type_specific = notification()
defaulted = notification()
_apply_hidden_ttl(with_expiry, kind(hidden_ttl_days=9), snapshot())
_apply_hidden_ttl(type_specific, kind(hidden_ttl_days=5), snapshot())
_apply_hidden_ttl(defaulted, kind(hidden_ttl_days=None), snapshot(3))
assert with_expiry.date_expired == existing
assert type_specific.date_expired == now + timedelta(days=5)
assert defaulted.date_expired == now + timedelta(days=3)
assert {with_expiry.visibility, type_specific.visibility, defaulted.visibility} == {"hidden"}
@pytest.mark.asyncio
async def test_hide_applies_ttl_without_marking_read(
monkeypatch: pytest.MonkeyPatch,
) -> None:
item = notification()
monkeypatch.setattr(
"app.notification_service.owned_notification",
AsyncMock(return_value=(item, kind(hidden_ttl_days=2))),
)
monkeypatch.setattr("app.notification_service.unread_count", AsyncMock(return_value=1))
session = SimpleNamespace(add=Mock(), commit=AsyncMock())
fanout = SimpleNamespace(publish_user=AsyncMock())
result = await apply_read_or_hide(
session,
item.user_id,
item.id,
"hide",
snapshot(),
fanout,
context(),
)
assert result["visibility"] == "hidden"
assert result["is_read"] is False
assert item.date_expired is not None
event = fanout.publish_user.await_args.args[1]
assert event["date_expired"] == item.date_expired
@pytest.mark.asyncio
async def test_cta_and_buttons_follow_catalog_lifecycle(
monkeypatch: pytest.MonkeyPatch,
) -> None:
item = notification()
cta_kind = kind(cta_action="open_detail")
monkeypatch.setattr(
"app.notification_service.owned_notification",
AsyncMock(return_value=(item, cta_kind)),
)
monkeypatch.setattr("app.notification_service.unread_count", AsyncMock(return_value=0))
session = SimpleNamespace(add=Mock(), commit=AsyncMock(), scalar=AsyncMock())
fanout = SimpleNamespace(publish_user=AsyncMock())
_, _, cta_result = await invoke_cta_state(
session, item.user_id, item.id, snapshot(), fanout, context()
)
assert item.is_read is True
assert item.visibility == "visible"
assert item.lifecycle_status == "active"
assert cta_result["result"]["action"] == "open_detail"
button = NotificationButton(
id=uuid.uuid4(),
code="done",
label="Готово",
sets_hidden=False,
applies_hidden_ttl=False,
close_reason="user_done",
submits_documents=False,
)
session.scalar.return_value = button
cta_kind.button_primary_code = "done"
result = await press_button(
session, item.user_id, item.id, "done", snapshot(), fanout, context()
)
assert result["lifecycle_status"] == "closed"
assert result["close_reason"] == "user_done"
assert item.closed_at is not None
@pytest.mark.asyncio
async def test_first_download_of_any_document_hides_once_and_preserves_expiry(
monkeypatch: pytest.MonkeyPatch,
) -> None:
expires = datetime.now(UTC) + timedelta(hours=1)
item = notification(date_expired=expires)
download_kind = kind(
code="docs_ready",
documents_allowed=True,
hide_on_document_download=True,
)
link = NotificationDocument(
id=uuid.uuid4(),
notification_id=item.id,
document_id=uuid.uuid4(),
sort_order=0,
download_url_issued_at=None,
)
document = Document(
id=link.document_id,
user_id=item.user_id,
name="result.pdf",
mime_type="application/pdf",
size_bytes=42,
checksum_sha256="a" * 64,
storage_bucket="documents",
object_key="documents/result.pdf",
sent_at=datetime.now(UTC),
)
row_result = SimpleNamespace(one_or_none=lambda: (link, document))
session = SimpleNamespace(
execute=AsyncMock(return_value=row_result),
scalar=AsyncMock(return_value=0),
add=Mock(),
commit=AsyncMock(),
)
monkeypatch.setattr(
"app.notification_service.owned_notification",
AsyncMock(return_value=(item, download_kind)),
)
monkeypatch.setattr("app.notification_service.unread_count", AsyncMock(return_value=0))
fanout = SimpleNamespace(publish_user=AsyncMock())
s3 = SimpleNamespace(presign_get=AsyncMock(return_value="https://download.example/file"))
result = await document_download(
session,
item.user_id,
item.id,
document.id,
snapshot(),
s3,
fanout,
context(),
)
assert result["download_url"] == "https://download.example/file"
assert item.is_read is True
assert item.visibility == "hidden"
assert item.date_expired == expires
assert link.download_url_issued_at is not None
fanout.publish_user.assert_awaited_once()
@pytest.mark.asyncio
async def test_source_token_hash_and_producer_authentication() -> None:
value = "producer-secret"
source = NotificationSource(
id=uuid.uuid4(),
code="producer_test",
description="test",
token_hash=source_token_hash(value),
token_rotated_at=None,
record_status="A",
)
session = SimpleNamespace(execute=AsyncMock(return_value=ScalarRows([source])))
assert await authenticate_source(session, f"Bearer {value}") is source
assert source.token_hash != value
with pytest.raises(DomainError) as invalid:
await authenticate_source(session, "Bearer wrong")
assert (invalid.value.code, invalid.value.status) == ("unauthorized", 401)
@pytest.mark.asyncio
async def test_catalog_dto_is_sorted_and_contains_only_public_behavior() -> None:
types = [
kind(code="news", contour="P", priority=4, button_primary_code="gotit"),
kind(
code="urgent",
contour="P",
priority=1,
label="Срочно",
button_primary_code="done",
button_secondary_code="later",
),
]
buttons = [
NotificationButton(id=uuid.uuid4(), code="done", label="Готово"),
NotificationButton(id=uuid.uuid4(), code="later", label="Позже"),
NotificationButton(id=uuid.uuid4(), code="gotit", label="Понятно"),
]
session = SimpleNamespace(
execute=AsyncMock(side_effect=[ScalarRows(types), ScalarRows(buttons)])
)
result = await catalog(session)
assert [item["code"] for item in result] == ["urgent", "news"]
assert result[0]["button_primary"] == {"code": "done", "label": "Готово"}
assert result[0]["button_secondary"] == {"code": "later", "label": "Позже"}
assert not {
"hidden_ttl_days",
"cta_sets_hidden",
"cta_close_reason",
"required_detail_blocks",
} & result[0].keys()
button_statement = session.execute.await_args_list[1].args[0]
assert "notification_buttons.record_status" in str(button_statement)
@pytest.mark.asyncio
async def test_realtime_uses_per_user_channel_and_strips_internal_identity() -> None:
user_id = uuid.uuid4()
class PubSub:
def __init__(self) -> None:
self.channels: tuple[str, ...] = ()
async def subscribe(self, *channels: str) -> None:
self.channels = channels
async def get_message(self, **_kwargs: object) -> dict[str, str]:
return {
"data": json.dumps(
{
"type": "notification.updated",
"_user_id": str(user_id),
"notification_id": str(uuid.uuid4()),
}
)
}
async def unsubscribe(self, *_channels: str) -> None:
return None
async def aclose(self) -> None:
return None
pubsub = PubSub()
redis = SimpleNamespace(publish=AsyncMock(), pubsub=lambda: pubsub)
fanout = RealtimeFanout(redis)
await fanout.publish_user(user_id, {"type": "notification.created"})
channel, payload = redis.publish.await_args.args
assert channel == USER_CHANNEL_PREFIX + str(user_id)
assert json.loads(payload)["_user_id"] == str(user_id)
stream = cast(
AsyncGenerator[dict[str, Any], None],
fanout.events(set(), user_id, notifications=True),
)
event = await anext(stream)
await stream.aclose()
assert pubsub.channels == (USER_CHANNEL_PREFIX + str(user_id),)
assert event["type"] == "notification.updated"
assert "_user_id" not in event
@pytest.mark.asyncio
async def test_expire_job_is_locked_set_based_and_commits() -> None:
session = SimpleNamespace(
scalar=AsyncMock(return_value=True),
execute=AsyncMock(
side_effect=[
SimpleNamespace(rowcount=3),
SimpleNamespace(rowcount=2),
]
),
add=Mock(),
commit=AsyncMock(),
)
assert await expire_notifications(session) == (3, 2)
assert session.execute.await_count == 2
personal_sql = str(session.execute.await_args_list[0].args[0])
guest_sql = str(session.execute.await_args_list[1].args[0])
assert "lifecycle_status" in personal_sql and "date_expired" in personal_sql
assert "lifecycle_status" in guest_sql and "date_expired" in guest_sql
session.commit.assert_awaited_once()
@pytest.mark.asyncio
async def test_draft_cleanup_deletes_objects_but_keeps_submitted_object(
monkeypatch: pytest.MonkeyPatch,
) -> None:
abandoned = ClientUploadDraft(
id=uuid.uuid4(),
user_id=uuid.uuid4(),
context_type="notification",
context_id=uuid.uuid4(),
original_file_name="a.pdf",
safe_file_name="a.pdf",
mime_type="application/pdf",
size_bytes=1,
storage_bucket="quarantine",
object_key="q/a",
quarantine_object_key="q/a",
state="draft",
)
submitted = ClientUploadDraft(
id=uuid.uuid4(),
user_id=abandoned.user_id,
context_type="notification",
context_id=abandoned.context_id,
original_file_name="b.pdf",
safe_file_name="b.pdf",
mime_type="application/pdf",
size_bytes=1,
storage_bucket="attachments",
object_key="a/b",
quarantine_object_key=None,
state="submitted",
)
session = SimpleNamespace(
execute=AsyncMock(side_effect=[ScalarRows([abandoned, submitted]), None, None]),
commit=AsyncMock(),
)
class Sessions:
async def __aenter__(self) -> object:
return session
async def __aexit__(self, *_args: object) -> None:
return None
db = SimpleNamespace(sessions=lambda: Sessions())
s3 = SimpleNamespace(delete_quarantine=AsyncMock(), delete=AsyncMock())
monkeypatch.setattr(
"app.workers.load_settings",
AsyncMock(
return_value=SettingsSnapshot(
{"notification.upload_draft.ttl_days": "7"}, "v1"
)
),
)
assert await notification_draft_cleanup_once(db, s3) == 2
s3.delete_quarantine.assert_awaited_once_with("q/a")
s3.delete.assert_not_awaited()
assert session.execute.await_count == 3
session.commit.assert_awaited_once()
+6
View File
@@ -68,6 +68,7 @@ docker compose --env-file .env config --quiet
- [ ] Token pairs match, PG verifies TLS, public URLs are HTTPS.
- [ ] Mock OTP risk is accepted and all secrets are unique >=128-bit values.
- [ ] `NOTIFICATIONS_TOKEN_PRODUCER_TEST` is unique and supplied only through secret/env; the `producer_test` source seed stores only its hash.
- [ ] `FRONTEND_DEV_PROXY_ENABLED=false` and Safety/nginx timeout budgets match.
## Gate 7 — images and static frontend
@@ -143,12 +144,15 @@ docker compose up -d redis
docker compose up -d keycloak otel-collector
docker compose up -d message-safety
docker compose up -d api-backend
docker compose up -d delivery-worker safety-recovery-worker cleanup-worker \
notification-expire-worker notification-draft-cleanup-worker
docker compose up -d bitrix-local-app bitrix-sync
docker compose up -d nginx
docker compose ps
```
- [ ] No restart loop/OOM; critical readiness is green.
- [ ] `notification-expire-worker` runs daily closure with an advisory lock; `notification-draft-cleanup-worker` removes expired drafts/S3 objects. Both entrypoints exist in the installed image.
- [ ] Only documented Bitrix not-installed/sync-stub degradation remains.
- [ ] External `/internal/*` is 404 and OTEL accepts telemetry.
@@ -169,6 +173,8 @@ deployment/scripts/smoke.sh
- [ ] Safety allow/deny/pending/timeout and one concurrent slow poll pass.
- [ ] File quarantine/promote/delete, owner-only download and audit pass.
- [ ] WS reconnect plus REST reconciliation, ownership 404, idempotency and 429 pass.
- [ ] Closed-network `producer_test` Create/Cancel smoke passes; identical Create returns `200`, changed payload returns `409`, and the external internal route returns `404`.
- [ ] Expire advisory locking and first download of any linked document are verified; hiding is one-time and an existing `date_expired` is preserved.
- [ ] Logs contain no PII, message body, token or presigned query.
## Gate 15 — observability
@@ -71,6 +71,7 @@ docker compose --env-file .env config --quiet
- [ ] Парные токены совпадают, PostgreSQL проверяет TLS, публичные URL используют HTTPS.
- [ ] Риск mock OTP принят; все секреты уникальны и содержат не менее 128 бит энтропии.
- [ ] `NOTIFICATIONS_TOKEN_PRODUCER_TEST` сгенерирован отдельно, передан только через secret/env; seed `notification_sources.code='producer_test'` содержит только его hash.
- [ ] Установлено `FRONTEND_DEV_PROXY_ENABLED=false`; таймауты Safety и nginx согласованы.
## Этап 7 — образы и статический frontend
@@ -165,12 +166,15 @@ docker compose up -d redis
docker compose up -d keycloak otel-collector
docker compose up -d message-safety
docker compose up -d api-backend
docker compose up -d delivery-worker safety-recovery-worker cleanup-worker \
notification-expire-worker notification-draft-cleanup-worker
docker compose up -d bitrix-local-app bitrix-sync
docker compose up -d nginx
docker compose ps
```
- [ ] Нет циклических перезапусков и OOM; критические readiness-проверки успешны.
- [ ] `notification-expire-worker` выполняет ежедневное закрытие с advisory lock; `notification-draft-cleanup-worker` очищает просроченные drafts/S3. Оба entrypoint присутствуют в установленном образе.
- [ ] Сохраняется только документированная деградация: Bitrix не установлен и bitrix-sync работает как заглушка.
- [ ] Внешний запрос `/internal/*` возвращает 404; OTEL принимает телеметрию.
@@ -191,6 +195,8 @@ deployment/scripts/smoke.sh
- [ ] Проверены Safety allow/deny/pending/timeout и один параллельный медленный poll.
- [ ] Проверены карантин, перенос и удаление файлов, скачивание только владельцем и аудит.
- [ ] Проверены переподключение WS с REST-сверкой, 404 при обращении к чужому ресурсу, идемпотентность и 429.
- [ ] От имени `producer_test` выполнены Create и Cancel через закрытый `/internal/notifications/v1/*`; тот же Create вернул `200`, изменённый payload — `409`, внешний запрос — `404`.
- [ ] Проверены expire job с advisory lock и первое скачивание любого связанного документа: уведомление скрывается один раз, а исходный `date_expired` не перезаписывается.
- [ ] Логи не содержат PII, текстов сообщений, токенов и query-параметров presigned URL.
## Этап 15 — наблюдаемость
@@ -32,6 +32,19 @@ settings:
rate_limit.download_url.per_user: {type: string, value: "60/hour", public: false}
rate_limit.public_endpoints.per_ip: {type: string, value: "60/minute", public: true}
rate_limit.login.per_ip: {type: string, value: "10/minute", public: true}
rate_limit.notifications_read.per_user: {type: string, value: "120/minute", public: false}
rate_limit.notifications_action.per_user: {type: string, value: "60/minute", public: false}
rate_limit.notification_upload.per_user: {type: string, value: "20/minute", public: false}
rate_limit.notifications_public.per_ip: {type: string, value: "60/minute", public: false}
notification.home.max_items: {type: integer, value: 7, public: false}
notification.center.max_items: {type: integer, value: 15, public: false}
notification.carousel.autoplay_enabled: {type: boolean, value: false, public: true}
notification.carousel.autoplay_interval_ms: {type: integer, value: 5000, public: true}
notification.hidden.default_ttl_days: {type: integer, value: 3, public: false}
notification.documents.max_files: {type: integer, value: 10, public: false}
notification.instruction.allowed_hosts: {type: string_list, value: "chat.example.ru", public: false}
notification.expire_job.run_at: {type: string, value: "00:01", public: false}
notification.upload_draft.ttl_days: {type: integer, value: 7, public: false}
ux.session.idle_timeout_minutes: {type: integer, value: 30, public: true}
security.cors.allowed_origins: {type: string_list, value: "https://chat.example.ru", public: false}
security.public_cache.max_age_seconds: {type: integer, value: 3600, public: false}
@@ -1,11 +1,21 @@
import { Stack } from "expo-router";
import { StatusBar } from "expo-status-bar";
import React from "react";
import React, { useEffect } from "react";
import { SafeAreaProvider, SafeAreaView } from "react-native-safe-area-context";
import { AppProvider } from "../src/app-context";
import { colors } from "../src/theme";
export default function RootLayout() {
useEffect(() => {
if (typeof window === "undefined") return;
const capture = (event: Event) => {
event.preventDefault();
(window as typeof window & { __hanInstallPrompt?: Event }).__hanInstallPrompt = event;
};
window.addEventListener("beforeinstallprompt", capture);
return () => window.removeEventListener("beforeinstallprompt", capture);
}, []);
return <SafeAreaProvider>
<AppProvider>
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background }}>
@@ -1,12 +1,13 @@
import { useQuery } from "@tanstack/react-query";
import { useRouter } from "expo-router";
import React, { useState } from "react";
import { useLocalSearchParams, useRouter } from "expo-router";
import React, { useEffect, useRef, useState } from "react";
import { ScrollView, Text, View } from "react-native";
import { useApp } from "../src/app-context";
import { AppHeader } from "../src/components/AppHeader";
import { ChatInputBar } from "../src/components/ChatInputBar";
import { ConsentModal } from "../src/components/ConsentModal";
import { HanLogo } from "../src/components/HanLogo";
import { NotificationCarousel } from "../src/components/NotificationCarousel";
import { PopularQuestionsList } from "../src/components/PopularQuestionsList";
import { QuickActions } from "../src/components/QuickActions";
import { ScreenShell } from "../src/components/ScreenShell";
@@ -29,8 +30,18 @@ export default function HomeScreen() {
const [message, setMessage] = useState("");
const [sendError, setSendError] = useState<unknown>();
const [sending, setSending] = useState(false);
const [afterNotificationAuth, setAfterNotificationAuth] = useState<(() => Promise<void>) | undefined>();
const { authorize: authorizeParam } = useLocalSearchParams<{ authorize?: string }>();
const handledAuthorizeParam = useRef(false);
const router = useRouter();
useEffect(() => {
if (authorizeParam === "1" && !handledAuthorizeParam.current && authStatus !== "authenticated") {
handledAuthorizeParam.current = true;
setConsentOpen(true);
}
}, [authStatus, authorizeParam]);
const sendAuthenticated = async (intent: PendingTextIntent) => {
setSending(true);
setSendError(undefined);
@@ -126,8 +137,11 @@ export default function HomeScreen() {
try {
const authorized = await authorize(consents);
if (authorized && pending) await sendAuthenticated(pending);
else if (authorized && afterNotificationAuth) await afterNotificationAuth();
} catch (error) {
setSendError(error);
} finally {
setAfterNotificationAuth(undefined);
}
};
@@ -149,6 +163,15 @@ export default function HomeScreen() {
{welcome ? (
<Text style={[styles.muted, { paddingHorizontal: 16, marginBottom: 8 }]}>{welcome}</Text>
) : null}
<NotificationCarousel
authenticated={authStatus === "authenticated"}
autoplay={config.data?.notification?.carousel_autoplay_enabled ?? false}
autoplayIntervalMs={config.data?.notification?.carousel_autoplay_interval_ms ?? 5000}
requireAuth={(afterAuth) => {
setAfterNotificationAuth(afterAuth ? () => afterAuth : undefined);
setConsentOpen(true);
}}
/>
</ScrollView>
<PopularQuestionsList questions={questions} onSelect={(text) => { setMessage(text); void send(text); }} />
<ChatInputBar
@@ -160,7 +183,7 @@ export default function HomeScreen() {
value={message}
/>
<QuickActions phone={config.data?.operator.call_phone} />
{sendError && (
{Boolean(sendError) && (
<View style={{ paddingHorizontal: 16, paddingBottom: 8 }}>
<ErrorNotice error={sendError} />
</View>
@@ -174,6 +197,7 @@ export default function HomeScreen() {
onCancel={() => {
setConsentOpen(false);
setPending(null);
setAfterNotificationAuth(undefined);
clearPendingTextIntent();
}}
/>
@@ -0,0 +1,286 @@
import { Feather } from "@expo/vector-icons";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useLocalSearchParams, useRouter } from "expo-router";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { Platform, Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
import { ApiError } from "../../src/api";
import { useApp } from "../../src/app-context";
import { ScreenShell } from "../../src/components/ScreenShell";
import { notificationApi, notificationKeys, uploadDraftApi } from "../../src/notification-api";
import { formatNotificationPrice, openNewTab, typeMap } from "../../src/notification-presenter";
import { publicApi } from "../../src/services";
import { colors, radii, spacing } from "../../src/theme";
import type { NotificationButton, UploadDraft } from "../../src/types";
import { Button, ErrorNotice, Loading, styles } from "../../src/ui";
export default function NotificationDetailScreen() {
const { id = "" } = useLocalSearchParams<{ id: string }>();
const { authStatus } = useApp();
const authenticated = authStatus === "authenticated";
const router = useRouter();
const client = useQueryClient();
const readSent = useRef(false);
const [error, setError] = useState<unknown>();
const detail = useQuery({
queryKey: notificationKeys.detail(id),
queryFn: () => notificationApi.detail(id),
enabled: authenticated && Boolean(id),
retry: (count, reason) => !(reason instanceof ApiError && reason.status === 404) && count < 1,
});
const catalog = useQuery({
queryKey: notificationKeys.catalog,
queryFn: notificationApi.catalog,
staleTime: Infinity,
});
const config = useQuery({ queryKey: ["public-config"], queryFn: publicApi.config });
const byCode = useMemo(() => typeMap(catalog.data ?? []), [catalog.data]);
const type = detail.data ? byCode.get(detail.data.notification_type) : undefined;
const canUpload = Boolean(detail.data?.details?.send_documents);
const drafts = useQuery({
queryKey: ["uploads", "notification", id],
queryFn: () => uploadDraftApi.list(id),
enabled: authenticated && Boolean(id) && canUpload,
});
const pending = drafts.data ?? detail.data?.details?.pending_documents ?? [];
const closed = detail.data?.lifecycle_status === "closed";
useEffect(() => {
if (!detail.data || readSent.current || detail.data.is_read !== false) return;
readSent.current = true;
void notificationApi.read(id).then((state) => {
client.setQueryData(notificationKeys.detail(id), { ...detail.data, ...state });
client.setQueryData(notificationKeys.counter, { unread_count: state.unread_count });
}).catch(setError);
}, [client, detail.data, id]);
const pressButton = useMutation({
mutationFn: (button: NotificationButton) => notificationApi.button(id, button.code),
onSuccess: async (state) => {
client.setQueryData(notificationKeys.counter, { unread_count: state.unread_count });
await client.invalidateQueries({ queryKey: ["notifications"] });
router.replace("/notifications");
},
onError: setError,
});
const removeDraft = useMutation({
mutationFn: uploadDraftApi.remove,
onSuccess: () => client.invalidateQueries({ queryKey: ["uploads", "notification", id] }),
onError: setError,
});
const chooseFile = () => {
if (Platform.OS !== "web") {
setError(new Error("Выбор файла в этой тестовой сборке доступен только в web."));
return;
}
const input = document.createElement("input");
input.type = "file";
input.multiple = true;
input.accept = (config.data?.attachments.allowed_mime_types ?? []).join(",");
input.onchange = () => {
const files = Array.from(input.files ?? []);
if (files.length) void uploadFiles(files);
};
input.click();
};
const uploadFiles = async (files: File[]) => {
const limits = config.data?.attachments;
const maxBytes = (limits?.max_size_mb ?? 5) * 1024 * 1024;
const allowed = limits?.allowed_mime_types ?? [];
if (pending.length + files.length > 10) {
setError(new Error("К одному уведомлению можно приложить не более 10 файлов."));
return;
}
const invalid = files.find((file) => file.size > maxBytes || (allowed.length > 0 && !allowed.includes(file.type)));
if (invalid) {
setError(new Error(`Файл «${invalid.name}» имеет недопустимый тип или размер.`));
return;
}
setError(undefined);
try {
for (const file of files) await uploadDraftApi.upload(id, file);
await drafts.refetch();
await detail.refetch();
} catch (reason) {
setError(reason);
}
};
const download = async (documentId: string) => {
setError(undefined);
try {
const result = await notificationApi.documentUrl(id, documentId);
openNewTab(result.download_url);
await Promise.all([detail.refetch(), client.invalidateQueries({ queryKey: ["notifications"] })]);
} catch (reason) {
setError(reason);
}
};
if (!authenticated) {
return (
<ScreenShell>
<DetailHeader title="Уведомление" onBack={() => router.replace("/notifications")} />
<View style={local.center}>
<Text style={styles.text}>Для просмотра уведомления требуется авторизация.</Text>
<Button title="Перейти в Центр" onPress={() => router.replace("/notifications")} />
</View>
</ScreenShell>
);
}
const unavailable = detail.error instanceof ApiError && detail.error.status === 404;
const notification = detail.data;
const details = notification?.details;
const price = formatNotificationPrice(notification?.price);
const oldPrice = formatNotificationPrice(notification?.old_price);
return (
<ScreenShell>
<DetailHeader title={type?.label ?? "Уведомление"} onBack={() => router.back()} />
<ScrollView contentContainerStyle={local.content}>
{(detail.isLoading || catalog.isLoading) && <Loading />}
{unavailable ? (
<View style={local.empty}>
<Feather name="slash" size={32} color={colors.mutedForeground} />
<Text style={styles.title}>Уведомление недоступно</Text>
<Text style={styles.muted}>Возможно, оно уже закрыто или было удалено.</Text>
</View>
) : (detail.error || catalog.error) ? (
<ErrorNotice error={detail.error ?? catalog.error} retry={() => { void detail.refetch(); void catalog.refetch(); }} />
) : notification ? (
<>
{closed && <Text style={styles.error}>Уведомление больше не актуально. Действия недоступны.</Text>}
{details?.deadline ? (
<View style={local.deadline}>
<Feather name="clock" size={16} color={colors.warning} />
<Text style={styles.text}>Срок: {new Date(details.deadline).toLocaleString("ru-RU")}</Text>
</View>
) : null}
<Text accessibilityRole="header" style={styles.title}>{details?.details_header ?? notification.header}</Text>
{details?.details_text || notification.text ? <Text style={styles.text}>{details?.details_text ?? notification.text}</Text> : null}
{price ? (
<View style={local.priceRow}>
<Text style={local.price}>{price}</Text>
{oldPrice ? <Text style={local.oldPrice}>{oldPrice}</Text> : null}
</View>
) : null}
{details?.todo_header ? <Text style={styles.heading}>{details.todo_header}</Text> : null}
{details?.todo_plan?.map((step) => (
<View key={`${step.number}-${step.text}`} style={local.step}>
<View style={local.stepNumber}><Text style={local.stepNumberText}>{step.number}</Text></View>
<Text style={[styles.text, { flex: 1 }]}>{step.text}</Text>
</View>
))}
{details?.documents?.length ? (
<View style={local.block}>
<Text style={styles.heading}>Документы</Text>
{details.documents.map((document) => (
<Pressable key={document.document_id} onPress={() => void download(document.document_id)} style={local.file}>
<Feather name="file-text" size={20} color={colors.info} />
<View style={{ flex: 1 }}>
<Text style={styles.text}>{document.title}</Text>
<Text style={styles.muted}>{formatBytes(document.size_bytes)}</Text>
</View>
<Feather name="download" size={18} color={colors.foreground} />
</Pressable>
))}
</View>
) : null}
{canUpload ? (
<View style={local.block}>
<Text style={styles.heading}>Приложить документы</Text>
<Text style={styles.muted}>Черновики сохраняются, пока вы не отправите или не удалите их.</Text>
{drafts.isLoading && <Loading />}
{pending.map((draft) => (
<DraftRow
key={draft.draft_id}
draft={draft}
disabled={removeDraft.isPending || closed}
onRemove={() => removeDraft.mutate(draft.draft_id)}
/>
))}
<Button title="Добавить файлы" secondary disabled={closed || pending.length >= 10} onPress={chooseFile} />
</View>
) : null}
<View style={local.buttons}>
{type?.button_primary ? (
<Button
title={type.button_primary.label}
disabled={closed || pressButton.isPending || (type.button_primary.code === "send_docs" && !pending.some((draft) => draft.scan_status === "clean"))}
onPress={() => pressButton.mutate(type.button_primary!)}
/>
) : null}
{type?.button_secondary ? (
<Button
secondary
title={type.button_secondary.label}
disabled={closed || pressButton.isPending}
onPress={() => pressButton.mutate(type.button_secondary!)}
/>
) : null}
</View>
{error && <ErrorNotice error={error} />}
</>
) : null}
</ScrollView>
</ScreenShell>
);
}
function DetailHeader({ title, onBack }: { title: string; onBack: () => void }) {
return (
<View style={local.header}>
<Pressable accessibilityLabel="Назад" accessibilityRole="button" onPress={onBack} style={local.back}>
<Feather name="arrow-left" size={20} color={colors.foreground} />
</Pressable>
<Text numberOfLines={1} style={[styles.heading, { flex: 1 }]}>{title}</Text>
</View>
);
}
function DraftRow({ draft, disabled, onRemove }: { draft: UploadDraft; disabled: boolean; onRemove: () => void }) {
const title = draft.title;
const status = {
pending: "Проверяется",
clean: "Готов к отправке",
infected: "Файл отклонён",
failed: "Ошибка проверки",
}[draft.scan_status];
return (
<View style={local.file}>
<Feather name={draft.scan_status === "clean" ? "check-circle" : "file"} size={20} color={draft.scan_status === "clean" ? colors.success : colors.warning} />
<View style={{ flex: 1 }}>
<Text style={styles.text}>{title}</Text>
<Text style={styles.muted}>{status} · {formatBytes(draft.size_bytes)}</Text>
</View>
<Pressable disabled={disabled} accessibilityLabel={`Удалить ${title}`} onPress={onRemove}>
<Feather name="trash-2" size={18} color={colors.destructive} />
</Pressable>
</View>
);
}
function formatBytes(bytes: number) {
if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))} КБ`;
return `${(bytes / 1024 / 1024).toFixed(1)} МБ`;
}
const local = StyleSheet.create({
header: { flexDirection: "row", alignItems: "center", gap: spacing.md, padding: spacing.lg, borderBottomWidth: 1, borderBottomColor: colors.border },
back: { width: 36, height: 36, borderRadius: radii.full, alignItems: "center", justifyContent: "center" },
content: { padding: spacing.lg, gap: spacing.md, paddingBottom: 40 },
center: { flex: 1, justifyContent: "center", gap: spacing.md, padding: spacing.lg },
empty: { alignItems: "center", gap: spacing.sm, paddingVertical: 48 },
deadline: { flexDirection: "row", alignItems: "center", gap: spacing.sm, borderRadius: radii.md, backgroundColor: "#fff7df", padding: spacing.md },
priceRow: { flexDirection: "row", alignItems: "baseline", gap: spacing.sm },
price: { fontSize: 22, fontWeight: "700", color: colors.foreground },
oldPrice: { fontSize: 14, color: colors.mutedForeground, textDecorationLine: "line-through" },
step: { flexDirection: "row", alignItems: "flex-start", gap: spacing.md },
stepNumber: { width: 28, height: 28, borderRadius: radii.full, alignItems: "center", justifyContent: "center", backgroundColor: colors.primary },
stepNumberText: { color: colors.primaryForeground, fontSize: 13, fontWeight: "700" },
block: { gap: spacing.sm, paddingVertical: spacing.sm },
file: { flexDirection: "row", alignItems: "center", gap: spacing.md, borderWidth: 1, borderColor: colors.border, borderRadius: radii.md, padding: spacing.md, backgroundColor: colors.card },
buttons: { gap: spacing.sm, paddingTop: spacing.sm },
});
@@ -0,0 +1,107 @@
import { Feather } from "@expo/vector-icons";
import { useQuery } from "@tanstack/react-query";
import { useRouter } from "expo-router";
import React, { useMemo, useState } from "react";
import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
import { useApp } from "../../src/app-context";
import { NotificationCard } from "../../src/components/NotificationCard";
import { ScreenShell } from "../../src/components/ScreenShell";
import { useNotificationAction } from "../../src/notification-actions";
import { notificationApi, notificationKeys } from "../../src/notification-api";
import { typeMap } from "../../src/notification-presenter";
import { colors, radii, spacing } from "../../src/theme";
import { Button, ErrorNotice, Loading, styles } from "../../src/ui";
export default function NotificationCenterScreen() {
const { authStatus } = useApp();
const authenticated = authStatus === "authenticated";
const router = useRouter();
const [actionError, setActionError] = useState<unknown>();
const catalog = useQuery({
queryKey: notificationKeys.catalog,
queryFn: notificationApi.catalog,
staleTime: Infinity,
});
const notifications = useQuery({
queryKey: notificationKeys.center,
queryFn: () => notificationApi.list("center"),
enabled: authenticated,
});
const byCode = useMemo(() => typeMap(catalog.data ?? []), [catalog.data]);
const action = useNotificationAction({
authenticated,
onError: setActionError,
requireAuth: () => router.replace({ pathname: "/", params: { authorize: "1" } }),
});
return (
<ScreenShell>
<View style={local.header}>
<Pressable accessibilityLabel="Назад" accessibilityRole="button" onPress={() => router.back()} style={local.back}>
<Feather name="arrow-left" size={20} color={colors.foreground} />
</Pressable>
<Text accessibilityRole="header" style={styles.title}>Центр уведомлений</Text>
</View>
{!authenticated ? (
<View style={local.gate}>
<View style={local.bell}>
<Feather name="bell" size={34} color={colors.primaryForeground} />
</View>
<Text style={styles.title}>Уведомления доступны после входа</Text>
<Text style={[styles.text, local.centerText]}>
Авторизуйтесь, чтобы видеть важные напоминания, документы и статусы услуг.
</Text>
<Button
title="Авторизоваться"
onPress={() => router.replace({ pathname: "/", params: { authorize: "1" } })}
/>
</View>
) : (
<ScrollView contentContainerStyle={local.content}>
{(notifications.isLoading || catalog.isLoading) && <Loading />}
{(notifications.error || catalog.error) && (
<ErrorNotice
error={notifications.error ?? catalog.error}
retry={() => { void notifications.refetch(); void catalog.refetch(); }}
/>
)}
{!notifications.isLoading && !notifications.error && notifications.data?.length === 0 && (
<View style={local.empty}>
<Feather name="check-circle" size={32} color={colors.success} />
<Text style={styles.heading}>Новых уведомлений нет</Text>
<Text style={styles.muted}>Здесь появятся важные сообщения и задачи.</Text>
</View>
)}
{notifications.data?.map((item) => (
<NotificationCard
key={item.id}
compact
item={item}
type={byCode.get(item.notification_type)}
onCta={() => void action(item, byCode.get(item.notification_type))}
/>
))}
{Boolean(actionError) && <ErrorNotice error={actionError} />}
</ScrollView>
)}
</ScreenShell>
);
}
const local = StyleSheet.create({
header: {
flexDirection: "row",
alignItems: "center",
gap: spacing.md,
padding: spacing.lg,
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
back: { width: 36, height: 36, borderRadius: radii.full, alignItems: "center", justifyContent: "center" },
gate: { flex: 1, justifyContent: "center", alignItems: "center", gap: spacing.md, padding: spacing.xl },
bell: { width: 68, height: 68, borderRadius: radii.full, alignItems: "center", justifyContent: "center", backgroundColor: colors.primary },
centerText: { textAlign: "center" },
content: { padding: spacing.lg, gap: spacing.md, paddingBottom: 40 },
empty: { alignItems: "center", gap: spacing.sm, paddingVertical: 48 },
});
@@ -109,7 +109,7 @@ export default function ProfileScreen() {
)}
<Button title="Выйти из аккаунта" danger onPress={() => void app.signOut()} />
{downloadError && <ErrorNotice error={downloadError} />}
{Boolean(downloadError) && <ErrorNotice error={downloadError} />}
</View>
</ScrollView>
</ScreenShell>
@@ -1,11 +1,13 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { QueryClient, QueryClientProvider, useQueryClient } from "@tanstack/react-query";
import { useRouter } from "expo-router";
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
import { AppState, Platform } from "react-native";
import { beginAuthorization, clearTokens, completeAuthorization, configureAuthFailure, getAccessToken, logout, refreshTokens } from "./auth";
import { sessionMemory } from "./api";
import { authApi, publicApi } from "./services";
import type { Consents } from "./types";
import { notificationApi, notificationKeys } from "./notification-api";
import { NotificationRealtimeClient } from "./realtime";
import type { Consents, NotificationItem, NotificationRealtimeEvent } from "./types";
type AuthStatus = "guest" | "authorizing" | "bootstrapping" | "authenticated";
type AppContextValue = {
@@ -105,7 +107,14 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
signOut: async () => { await logout(); toGuest(); router.replace("/"); },
}), [authStatus, realtimeState, authorize, finishCallback, ensureSession, toGuest, router]);
return <QueryClientProvider client={queryClient}><Context.Provider value={value}>{children}</Context.Provider></QueryClientProvider>;
return (
<QueryClientProvider client={queryClient}>
<Context.Provider value={value}>
<NotificationRealtimeBridge authenticated={authStatus === "authenticated"} onState={setRealtimeState} />
{children}
</Context.Provider>
</QueryClientProvider>
);
}
export function useApp() {
@@ -118,3 +127,47 @@ export async function resetAuthForTests() {
await clearTokens();
queryClient.clear();
}
function NotificationRealtimeBridge({
authenticated,
onState,
}: {
authenticated: boolean;
onState: (state: string) => void;
}) {
const client = useQueryClient();
useEffect(() => {
if (!authenticated) return;
const updateFromEvent = (event: NotificationRealtimeEvent) => {
client.setQueryData(notificationKeys.counter, { unread_count: event.unread_count });
if (event.type === "notification.updated") {
client.setQueryData(notificationKeys.detail(event.notification_id), (old: NotificationItem | undefined) =>
old ? { ...old, ...event } : old);
}
if (event.type === "notification.closed") {
client.setQueryData(notificationKeys.detail(event.notification_id), (old: NotificationItem | undefined) =>
old ? { ...old, lifecycle_status: "closed" as const } : old);
}
void client.invalidateQueries({ queryKey: ["notifications"] });
};
const reconcile = async () => {
const [home, center, counter] = await Promise.all([
notificationApi.list("home"),
notificationApi.list("center"),
notificationApi.counter(),
]);
client.setQueryData(notificationKeys.home(true), home);
client.setQueryData(notificationKeys.center, center);
client.setQueryData(notificationKeys.counter, counter);
};
const realtime = new NotificationRealtimeClient(updateFromEvent, reconcile, onState);
realtime.start();
return () => realtime.stop();
}, [authenticated, client, onState]);
return null;
}
@@ -1,16 +1,35 @@
import { Feather } from "@expo/vector-icons";
import { useQuery } from "@tanstack/react-query";
import { Link } from "expo-router";
import React from "react";
import { Pressable, StyleSheet, Text, View } from "react-native";
import { useApp } from "../app-context";
import { notificationApi, notificationKeys } from "../notification-api";
import { colors, radii, spacing } from "../theme";
export function AppHeader({ guestLabel }: { guestLabel?: string }) {
export function AppHeader({ guestLabel }: { guestLabel?: string | undefined }) {
const { authStatus } = useApp();
const authenticated = authStatus === "authenticated";
const counter = useQuery({
queryKey: notificationKeys.counter,
queryFn: notificationApi.counter,
enabled: authenticated,
});
const unread = counter.data?.unread_count ?? 0;
return (
<View style={styles.header}>
<Link href="/dialogs" asChild>
<Pressable accessibilityRole="link" style={({ pressed }) => [styles.historyLink, pressed && styles.pressed]}>
<Feather name="clock" size={20} color={colors.foreground} />
<Text style={styles.historyText}>История</Text>
<Link href="/notifications" asChild>
<Pressable accessibilityRole="link" style={({ pressed }) => [styles.centerLink, pressed && styles.pressed]}>
<View>
<Feather name="bell" size={20} color={colors.foreground} />
{authenticated && unread > 0 && (
<View accessibilityLabel={`${unread} непрочитанных уведомлений`} style={styles.notificationBadge}>
<Text style={styles.notificationBadgeText}>{unread > 99 ? "99+" : unread}</Text>
</View>
)}
</View>
<Text style={styles.centerText}>Центр</Text>
</Pressable>
</Link>
@@ -38,8 +57,23 @@ const styles = StyleSheet.create({
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
historyLink: { flexDirection: "row", alignItems: "center", gap: spacing.sm },
historyText: { fontSize: 14, color: colors.foreground },
centerLink: { flexDirection: "row", alignItems: "center", gap: spacing.sm },
centerText: { fontSize: 14, color: colors.foreground },
notificationBadge: {
position: "absolute",
top: -9,
right: -12,
minWidth: 18,
height: 18,
borderRadius: radii.full,
paddingHorizontal: 4,
alignItems: "center",
justifyContent: "center",
backgroundColor: colors.destructive,
borderWidth: 2,
borderColor: colors.background,
},
notificationBadgeText: { color: colors.primaryForeground, fontSize: 9, fontWeight: "700" },
right: { flexDirection: "row", alignItems: "center", gap: spacing.sm },
guestBadge: { fontSize: 12, color: colors.mutedForeground },
avatar: {
@@ -13,8 +13,8 @@ const statusLabel: Record<string, string> = {
type Props = {
message: Message;
getAttachmentUrl?: (attachmentId: string) => Promise<string>;
onAttachmentError?: (error: unknown) => void;
getAttachmentUrl?: ((attachmentId: string) => Promise<string>) | undefined;
onAttachmentError?: ((error: unknown) => void) | undefined;
};
export function MessageBubble({ message, getAttachmentUrl, onAttachmentError }: Props) {
@@ -54,9 +54,9 @@ export function MessageBubble({ message, getAttachmentUrl, onAttachmentError }:
function AttachmentPreview({ attachment, getUrl, isClient, onError }: {
attachment: Attachment;
getUrl?: (attachmentId: string) => Promise<string>;
getUrl?: ((attachmentId: string) => Promise<string>) | undefined;
isClient: boolean;
onError?: (error: unknown) => void;
onError?: ((error: unknown) => void) | undefined;
}) {
const [previewUrl, setPreviewUrl] = useState<string>();
const [previewFailed, setPreviewFailed] = useState(false);
@@ -0,0 +1,98 @@
import { Feather } from "@expo/vector-icons";
import React from "react";
import { Pressable, StyleSheet, Text, View } from "react-native";
import { formatNotificationPrice, notificationIcon, notificationPalette } from "../notification-presenter";
import { radii, spacing } from "../theme";
import type { NotificationItem, NotificationType } from "../types";
export function NotificationCard({
item,
type,
onCta,
onHide,
compact = false,
disabled = false,
}: {
item: NotificationItem;
type: NotificationType | undefined;
onCta: () => void;
onHide?: () => void;
compact?: boolean;
disabled?: boolean;
}) {
const palette = notificationPalette(type?.color_token);
const price = formatNotificationPrice(item.price);
const oldPrice = formatNotificationPrice(item.old_price);
const deadline = item.details?.deadline;
return (
<View style={[
local.card,
compact && local.compact,
{ backgroundColor: palette.background, borderColor: `${palette.accent}40` },
]}>
<View style={local.headerRow}>
<View style={local.labelRow}>
<Feather name={notificationIcon(type?.icon_code)} size={18} color={palette.accent} />
<Text style={[local.label, { color: palette.foreground }]}>{type?.label ?? "Уведомление"}</Text>
{type?.countable && item.is_read === false && <View accessibilityLabel="Непрочитано" style={[local.unread, { backgroundColor: palette.accent }]} />}
</View>
{onHide && (
<Pressable accessibilityLabel="Скрыть уведомление" accessibilityRole="button" hitSlop={10} onPress={onHide}>
<Feather name="x" size={18} color={palette.foreground} />
</Pressable>
)}
</View>
<Text style={[local.title, { color: palette.foreground }]}>{item.header}</Text>
{item.text ? <Text numberOfLines={compact ? 2 : 3} style={[local.text, { color: palette.foreground }]}>{item.text}</Text> : null}
{deadline ? (
<View style={local.deadline}>
<Feather name="clock" size={14} color={palette.foreground} />
<Text style={[local.meta, { color: palette.foreground }]}>до {new Date(deadline).toLocaleDateString("ru-RU")}</Text>
</View>
) : null}
{price ? (
<View style={local.priceRow}>
<Text style={[local.price, { color: palette.foreground }]}>{price}</Text>
{oldPrice ? <Text style={[local.oldPrice, { color: palette.foreground }]}>{oldPrice}</Text> : null}
</View>
) : null}
<Pressable
accessibilityRole="button"
disabled={disabled}
onPress={onCta}
style={({ pressed }) => [local.cta, { backgroundColor: palette.accent }, pressed && local.pressed, disabled && local.disabled]}
>
<Text style={local.ctaText}>{type?.cta_text ?? "Подробнее →"}</Text>
</Pressable>
</View>
);
}
const local = StyleSheet.create({
card: {
width: 326,
minHeight: 190,
borderWidth: 1,
borderRadius: radii.xl,
padding: spacing.lg,
gap: spacing.sm,
},
compact: { width: "100%", minHeight: 0 },
headerRow: { flexDirection: "row", justifyContent: "space-between", alignItems: "center" },
labelRow: { flexDirection: "row", alignItems: "center", gap: spacing.sm, flexShrink: 1 },
label: { fontSize: 12, fontWeight: "600", textTransform: "uppercase", letterSpacing: 0.4 },
unread: { width: 8, height: 8, borderRadius: radii.full },
title: { fontSize: 18, lineHeight: 23, fontWeight: "600" },
text: { fontSize: 14, lineHeight: 20, opacity: 0.88 },
deadline: { flexDirection: "row", alignItems: "center", gap: spacing.xs },
meta: { fontSize: 12 },
priceRow: { flexDirection: "row", alignItems: "baseline", gap: spacing.sm },
price: { fontSize: 18, fontWeight: "700" },
oldPrice: { fontSize: 13, textDecorationLine: "line-through", opacity: 0.65 },
cta: { alignSelf: "flex-start", minHeight: 38, justifyContent: "center", borderRadius: radii.md, paddingHorizontal: spacing.md, marginTop: "auto" },
ctaText: { color: "#ffffff", fontSize: 14, fontWeight: "600" },
pressed: { opacity: 0.78 },
disabled: { opacity: 0.5 },
});
@@ -0,0 +1,121 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { FlatList, StyleSheet, Text, View } from "react-native";
import { notificationApi, notificationKeys } from "../notification-api";
import { useNotificationAction } from "../notification-actions";
import { typeMap } from "../notification-presenter";
import { colors, spacing } from "../theme";
import type { NotificationItem } from "../types";
import { ErrorNotice, Loading, styles } from "../ui";
import { NotificationCard } from "./NotificationCard";
export function NotificationCarousel({
authenticated,
autoplay = false,
autoplayIntervalMs = 5000,
requireAuth,
}: {
authenticated: boolean;
autoplay?: boolean;
autoplayIntervalMs?: number;
requireAuth: (afterAuth?: () => Promise<void>) => void;
}) {
const client = useQueryClient();
const list = useRef<FlatList<NotificationItem>>(null);
const [activeIndex, setActiveIndex] = useState(0);
const [actionError, setActionError] = useState<unknown>();
const catalog = useQuery({
queryKey: notificationKeys.catalog,
queryFn: notificationApi.catalog,
staleTime: Infinity,
});
const notifications = useQuery({
queryKey: notificationKeys.home(authenticated),
queryFn: authenticated ? () => notificationApi.list("home") : notificationApi.guestHome,
});
const byCode = useMemo(() => typeMap(catalog.data ?? []), [catalog.data]);
const action = useNotificationAction({ authenticated, requireAuth, onError: setActionError });
const hide = useMutation({
mutationFn: notificationApi.hide,
onSuccess: async () => {
await Promise.all([
client.invalidateQueries({ queryKey: notificationKeys.home(true) }),
client.invalidateQueries({ queryKey: notificationKeys.center }),
]);
},
onError: setActionError,
});
const data = notifications.data ?? [];
useEffect(() => {
if (!autoplay || data.length < 2) return;
const timer = setInterval(() => {
setActiveIndex((current) => {
const next = (current + 1) % data.length;
list.current?.scrollToIndex({ index: next, animated: true });
return next;
});
}, Math.max(1000, autoplayIntervalMs));
return () => clearInterval(timer);
}, [autoplay, autoplayIntervalMs, data.length]);
if (notifications.isLoading || catalog.isLoading) {
return <View style={local.state}><Loading /></View>;
}
if (notifications.error || catalog.error) {
return (
<View style={local.state}>
<ErrorNotice
error={notifications.error ?? catalog.error}
retry={() => { void notifications.refetch(); void catalog.refetch(); }}
/>
</View>
);
}
if (!data.length) return null;
return (
<View style={local.section}>
<Text accessibilityRole="header" style={[styles.heading, local.heading]}>Важное для вас</Text>
<FlatList
ref={list}
horizontal
data={data}
keyExtractor={(item) => item.id}
contentContainerStyle={local.content}
ItemSeparatorComponent={() => <View style={{ width: spacing.md }} />}
onMomentumScrollEnd={(event) => {
const width = event.nativeEvent.layoutMeasurement.width;
if (width > 0) setActiveIndex(Math.round(event.nativeEvent.contentOffset.x / width));
}}
renderItem={({ item }) => (
<NotificationCard
disabled={hide.isPending}
item={item}
type={byCode.get(item.notification_type)}
onCta={() => void action(item, byCode.get(item.notification_type))}
{...(authenticated ? { onHide: () => hide.mutate(item.id) } : {})}
/>
)}
showsHorizontalScrollIndicator={false}
/>
{data.length > 1 && (
<View style={local.dots} accessibilityLabel={`${activeIndex + 1} из ${data.length}`}>
{data.map((item, index) => <View key={item.id} style={[local.dot, index === activeIndex && local.dotActive]} />)}
</View>
)}
{Boolean(actionError) && <View style={local.error}><ErrorNotice error={actionError} /></View>}
</View>
);
}
const local = StyleSheet.create({
section: { paddingVertical: spacing.md },
heading: { paddingHorizontal: spacing.lg, marginBottom: spacing.sm },
content: { paddingHorizontal: spacing.lg },
state: { paddingHorizontal: spacing.lg, paddingVertical: spacing.md },
dots: { flexDirection: "row", justifyContent: "center", gap: 6, marginTop: spacing.sm },
dot: { width: 6, height: 6, borderRadius: 3, backgroundColor: colors.muted },
dotActive: { width: 16, backgroundColor: colors.primary },
error: { paddingHorizontal: spacing.lg, paddingTop: spacing.sm },
});
@@ -3,7 +3,7 @@ import React from "react";
import { Linking, Pressable, StyleSheet, Text } from "react-native";
import { colors, radii, spacing } from "../theme";
export function QuickActions({ phone }: { phone?: string }) {
export function QuickActions({ phone }: { phone?: string | undefined }) {
const call = () => {
if (phone) void Linking.openURL(`tel:${phone}`);
};
@@ -0,0 +1,96 @@
import { useQueryClient } from "@tanstack/react-query";
import { useRouter } from "expo-router";
import { useCallback } from "react";
import { dialogApi } from "./services";
import { notificationApi } from "./notification-api";
import { actionDialogId, actionUrl, openNewTab } from "./notification-presenter";
import {
clearPendingTextIntent,
createPendingTextIntent,
savePendingTextIntent,
type PendingTextIntent,
} from "./pending-intent";
import type { NotificationItem, NotificationType } from "./types";
type InstallPromptEvent = Event & {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
};
export function useNotificationAction({
authenticated,
requireAuth,
onError,
}: {
authenticated: boolean;
requireAuth?: (afterAuth?: () => Promise<void>) => void;
onError: (error: unknown) => void;
}) {
const router = useRouter();
const client = useQueryClient();
const sendGuestOffer = useCallback(async (intent: PendingTextIntent) => {
const dialog = await dialogApi.create(intent.dialogKey);
await dialogApi.sendText(dialog.dialog_id, intent.text, intent.messageKey);
clearPendingTextIntent();
router.push(`/dialogs/${dialog.dialog_id}`);
}, [router]);
return useCallback(async (item: NotificationItem, type?: NotificationType) => {
if (!type) return;
onError(undefined);
try {
if (!authenticated) {
if (type.cta_action === "install_app_prompt") {
await promptInstallOrOpenInstruction(item.instruction_url);
return;
}
if (type.cta_action === "send_chat_message" && item.chat_message_text) {
const intent = createPendingTextIntent(item.chat_message_text);
savePendingTextIntent(intent);
requireAuth?.(() => sendGuestOffer(intent));
return;
}
requireAuth?.();
return;
}
const state = await notificationApi.cta(item.id);
client.setQueryData(notificationApiStateKey(item.id), (old: NotificationItem | undefined) =>
old ? { ...old, ...state } : old);
await Promise.all([
client.invalidateQueries({ queryKey: ["notifications"] }),
client.invalidateQueries({ queryKey: ["notifications", "counter"] }),
]);
if (type.cta_action === "open_detail") {
router.push(`/notification/${item.id}`);
return;
}
const url = actionUrl(state);
if (url) openNewTab(url);
const dialogId = actionDialogId(state);
if (dialogId) router.push(`/dialogs/${dialogId}`);
else if (type.cta_action === "send_chat_message") router.push("/dialogs");
} catch (error) {
onError(error);
}
}, [authenticated, client, onError, requireAuth, router, sendGuestOffer]);
}
function notificationApiStateKey(id: string) {
return ["notifications", "detail", id] as const;
}
async function promptInstallOrOpenInstruction(instructionUrl?: string | null) {
const event = typeof window !== "undefined"
? (window as typeof window & { __hanInstallPrompt?: InstallPromptEvent }).__hanInstallPrompt
: undefined;
if (event) {
await event.prompt();
const choice = await event.userChoice;
if (choice.outcome === "accepted") return;
}
if (!instructionUrl) throw new Error("Инструкция по установке недоступна");
openNewTab(instructionUrl);
}
@@ -0,0 +1,108 @@
import { apiRequest, json } from "./api";
import type {
NotificationActionState,
NotificationCounter,
NotificationItem,
NotificationList,
NotificationType,
UploadDraft,
} from "./types";
type CatalogResponse = { items: NotificationType[] };
type UploadListResponse = { items: UploadDraft[] };
export const notificationKeys = {
catalog: ["notification-types"] as const,
home: (authenticated: boolean) => ["notifications", authenticated ? "P" : "G", "home"] as const,
center: ["notifications", "P", "center"] as const,
counter: ["notifications", "counter"] as const,
detail: (id: string) => ["notifications", "detail", id] as const,
};
export const notificationApi = {
catalog: async () => (await apiRequest<CatalogResponse>("/api/v1/public/notification-types")).items,
guestHome: async () => (await apiRequest<NotificationList>("/api/v1/public/notifications")).items,
list: async (place: "home" | "center") =>
(await apiRequest<NotificationList>(
`/api/v1/notifications?place=${place}`,
{ protected: true },
)).items,
counter: () =>
apiRequest<NotificationCounter>("/api/v1/notifications/counter", { protected: true }),
detail: (id: string) =>
apiRequest<NotificationItem>(`/api/v1/notifications/${encodeURIComponent(id)}`, { protected: true }),
read: (id: string) =>
apiRequest<NotificationActionState>(`/api/v1/notifications/${encodeURIComponent(id)}/read`, {
method: "POST", protected: true, body: "{}",
}),
hide: (id: string) =>
apiRequest<NotificationActionState>(`/api/v1/notifications/${encodeURIComponent(id)}/hide`, {
method: "POST", protected: true, body: "{}",
}),
cta: (id: string) =>
apiRequest<NotificationActionState>(`/api/v1/notifications/${encodeURIComponent(id)}/cta`, {
method: "POST", protected: true, body: "{}",
}),
button: (id: string, code: string) =>
apiRequest<NotificationActionState>(
`/api/v1/notifications/${encodeURIComponent(id)}/buttons/${encodeURIComponent(code)}`,
{ method: "POST", protected: true, body: "{}" },
),
documentUrl: (notificationId: string, documentId: string) =>
apiRequest<{ download_url: string; expires_at: string }>(
`/api/v1/notifications/${encodeURIComponent(notificationId)}/documents/${encodeURIComponent(documentId)}/download-url`,
{ protected: true },
),
};
export const uploadDraftApi = {
list: async (notificationId: string) =>
(await apiRequest<UploadListResponse>(
`/api/v1/uploads?context_type=notification&context_id=${encodeURIComponent(notificationId)}`,
{ protected: true },
)).items,
remove: (draftId: string) =>
apiRequest<void>(`/api/v1/uploads/${encodeURIComponent(draftId)}`, {
method: "DELETE", protected: true,
}),
upload: async (notificationId: string, file: File) => {
const checksum = await sha256(file);
const draft = await apiRequest<{
draft_id: string;
upload_url: string;
upload_headers: Record<string, string>;
expires_at: string;
}>("/api/v1/uploads/init", {
method: "POST",
protected: true,
body: json({
context_type: "notification",
context_id: notificationId,
file_name: file.name,
mime_type: file.type,
size_bytes: file.size,
}),
});
const upload = await fetch(draft.upload_url, {
method: "PUT",
headers: draft.upload_headers ?? { "Content-Type": file.type },
body: file,
});
if (!upload.ok) throw new Error("Не удалось загрузить файл в хранилище");
await apiRequest<UploadDraft>(`/api/v1/uploads/${encodeURIComponent(draft.draft_id)}/complete`, {
method: "POST",
protected: true,
body: json({ checksum }),
});
return draft.draft_id;
},
};
async function sha256(file: Blob) {
const digest = await crypto.subtle.digest("SHA-256", await file.arrayBuffer());
const hex = Array.from(
new Uint8Array(digest),
(byte) => byte.toString(16).padStart(2, "0"),
).join("");
return `sha256:${hex}`;
}
@@ -0,0 +1,80 @@
import { Feather } from "@expo/vector-icons";
import type { NotificationActionState, NotificationType } from "./types";
export type NotificationPalette = {
background: string;
foreground: string;
accent: string;
};
const neutral: NotificationPalette = {
background: "#f3f3f5",
foreground: "#252525",
accent: "#030213",
};
const palettes: Record<string, NotificationPalette> = {
critical: { background: "#feecef", foreground: "#7f1d1d", accent: "#d4183d" },
warning: { background: "#fff7df", foreground: "#713f12", accent: "#ca8a04" },
success: { background: "#eaf8ee", foreground: "#14532d", accent: "#16a34a" },
info: { background: "#eaf2ff", foreground: "#1e3a8a", accent: "#2563eb" },
promo: { background: "#f4edff", foreground: "#4c1d95", accent: "#7c3aed" },
neutral,
};
const icons: Record<string, keyof typeof Feather.glyphMap> = {
alert: "alert-circle",
urgent: "alert-triangle",
payment: "credit-card",
documents: "file-text",
document: "file-text",
status: "activity",
reminder: "clock",
news: "bell",
promo: "gift",
ads: "star",
authorize: "log-in",
install: "download",
info: "info",
};
export function notificationPalette(token?: string | null): NotificationPalette {
return token ? (palettes[token] ?? neutral) : neutral;
}
export function notificationIcon(code?: string | null): keyof typeof Feather.glyphMap {
return code ? (icons[code] ?? "bell") : "bell";
}
export function typeMap(catalog: NotificationType[]) {
return new Map(catalog.map((type) => [type.code, type]));
}
export function formatNotificationPrice(value?: number | string | null) {
if (value === null || value === undefined) return null;
const number = Number(value);
if (!Number.isFinite(number)) return null;
return new Intl.NumberFormat("ru-RU", {
style: "currency",
currency: "RUB",
maximumFractionDigits: number % 1 === 0 ? 0 : 2,
}).format(number);
}
export function actionUrl(state: NotificationActionState) {
return state.result?.action === "open_url" ? state.result.url : undefined;
}
export function actionDialogId(state: NotificationActionState) {
return state.result?.action === "chat_message_sent"
? state.result.message.dialog_id
: undefined;
}
export function openNewTab(url: string) {
if (typeof window !== "undefined") {
window.open(url, "_blank", "noopener,noreferrer");
return;
}
void import("react-native").then(({ Linking }) => Linking.openURL(url));
}
@@ -1,7 +1,7 @@
import { env } from "./config";
import { getAccessToken, refreshTokens } from "./auth";
import { dialogApi } from "./services";
import type { Message } from "./types";
import type { Message, NotificationRealtimeEvent } from "./types";
export { reconcileMessages } from "./reconcile";
export type RealtimeState = "idle" | "connecting" | "websocket" | "polling";
@@ -10,6 +10,9 @@ export type RealtimeEvent =
| { type: "message.status"; dialog_id: string; message_id: string; safety_status: Message["safety_status"]; delivery_status: Message["delivery_status"]; cursor?: string }
| { type: "dialog.status"; dialog_id: string; status: string; cursor?: string };
export const NOTIFICATION_OUTAGE_MS = 30_000;
export const NOTIFICATION_POLL_INTERVAL_MS = 60_000;
const safeCursors = new Map<string, string>();
export const getRealtimeDiagnostics = () =>
[...safeCursors.entries()].map(([dialogId, cursor]) => ({
@@ -26,8 +29,8 @@ export function websocketJwtProtocol(token: string) {
export class RealtimeClient {
private socket?: WebSocket;
private reconnectTimer?: ReturnType<typeof setTimeout>;
private pollingTimer?: ReturnType<typeof setTimeout>;
private reconnectTimer: ReturnType<typeof setTimeout> | undefined;
private pollingTimer: ReturnType<typeof setTimeout> | undefined;
private disconnectedAt = 0;
private attempt = 0;
private stopped = true;
@@ -143,3 +146,114 @@ export class RealtimeClient {
this.pollingTimer = undefined;
}
}
export class NotificationRealtimeClient {
private socket?: WebSocket;
private reconnectTimer: ReturnType<typeof setTimeout> | undefined;
private pollingTimer: ReturnType<typeof setTimeout> | undefined;
private outageTimer: ReturnType<typeof setTimeout> | undefined;
private disconnectedAt = 0;
private attempt = 0;
private stopped = true;
private readonly eventIds = new Set<string>();
constructor(
private readonly onEvent: (event: NotificationRealtimeEvent) => void,
private readonly reconcile: () => Promise<void>,
private readonly onState: (state: RealtimeState) => void,
) {}
start() {
if (!this.stopped) return;
this.stopped = false;
this.connect();
}
stop() {
this.stopped = true;
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
if (this.pollingTimer) clearTimeout(this.pollingTimer);
if (this.outageTimer) clearTimeout(this.outageTimer);
this.socket?.close();
this.onState("idle");
}
private connect() {
if (this.stopped) return;
const token = getAccessToken();
if (!token) return;
this.onState("connecting");
const url = env.apiBaseUrl.replace(/^http/, "ws") + "/api/v1/realtime";
this.socket = new WebSocket(url, ["han-chat-v1", websocketJwtProtocol(token)]);
this.socket.onopen = () => {
this.attempt = 0;
this.socket?.send(JSON.stringify({ type: "subscribe", dialog_ids: [], notifications: true }));
void this.reconcile().then(() => {
this.disconnectedAt = 0;
this.stopPolling();
this.onState("websocket");
}).catch(() => undefined);
};
this.socket.onmessage = ({ data }) => {
try {
const event = JSON.parse(String(data)) as NotificationRealtimeEvent | { type: string };
if (event.type === "ping") {
this.socket?.send(JSON.stringify({ type: "pong" }));
return;
}
if (
event.type === "notification.created"
|| event.type === "notification.updated"
|| event.type === "notification.closed"
) {
const notificationEvent = event as NotificationRealtimeEvent;
if (this.eventIds.has(notificationEvent.event_id)) return;
this.eventIds.add(notificationEvent.event_id);
if (this.eventIds.size > 100) {
const oldest = this.eventIds.values().next().value as string | undefined;
if (oldest) this.eventIds.delete(oldest);
}
this.onEvent(notificationEvent);
}
} catch { /* malformed and unknown messages are ignored */ }
};
this.socket.onclose = (event) => {
if (this.stopped) return;
if (!this.disconnectedAt) {
this.disconnectedAt = Date.now();
this.outageTimer = setTimeout(() => this.startPolling(), NOTIFICATION_OUTAGE_MS);
}
if (event.code === 4401 || event.code === 1008) {
void refreshTokens().finally(() => this.scheduleReconnect());
} else {
this.scheduleReconnect();
}
};
this.socket.onerror = () => this.socket?.close();
}
private scheduleReconnect() {
if (this.stopped) return;
const base = Math.min(30_000, 1000 * 2 ** this.attempt++);
const delay = Math.round(base * (0.8 + Math.random() * 0.4));
this.reconnectTimer = setTimeout(() => this.connect(), delay);
}
private startPolling() {
if (this.stopped || this.pollingTimer || !this.disconnectedAt) return;
this.onState("polling");
const poll = async () => {
if (this.stopped) return;
await this.reconcile().catch(() => undefined);
this.pollingTimer = setTimeout(poll, NOTIFICATION_POLL_INTERVAL_MS);
};
void poll();
}
private stopPolling() {
if (this.pollingTimer) clearTimeout(this.pollingTimer);
if (this.outageTimer) clearTimeout(this.outageTimer);
this.pollingTimer = undefined;
this.outageTimer = undefined;
}
}
@@ -56,6 +56,10 @@ export type DocumentItem = {
export type PublicConfig = {
auth: { phone_enabled: boolean; password_enabled: boolean };
operator: { call_phone: string };
notification?: {
carousel_autoplay_enabled?: boolean;
carousel_autoplay_interval_ms?: number;
};
consents: Record<string, {
required: boolean;
document_url: string | null;
@@ -75,3 +79,134 @@ export type PublicContent = {
popular_questions: Array<{ id: string; mnemonic: string; text: string }>;
version: string;
};
export type NotificationContour = "G" | "P";
export type NotificationCtaAction =
| "open_detail"
| "open_payment_url"
| "send_chat_message"
| "start_auth"
| "install_app_prompt";
export type NotificationButton = {
code: string;
label: string;
};
export type NotificationType = {
code: string;
label: string;
color_token: string;
icon_code: string | null;
cta_text: string;
cta_action: NotificationCtaAction;
countable: boolean;
contour: NotificationContour;
button_primary: NotificationButton | null;
button_secondary: NotificationButton | null;
};
export type NotificationTodoItem = {
number: number;
text: string;
};
export type NotificationDocument = {
document_id: string;
title: string;
mime_type: string;
size_bytes: number;
};
export type UploadDraft = {
draft_id: string;
context_type: "notification";
context_id: string;
title: string;
mime_type: string;
size_bytes: number;
scan_status: "pending" | "clean" | "infected" | "failed";
state: "draft" | "submitted" | "discarded";
};
export type NotificationDetails = {
deadline?: string | null;
details_header?: string | null;
details_text?: string | null;
todo_header?: string | null;
todo_plan?: NotificationTodoItem[] | null;
send_documents?: boolean;
pending_documents?: UploadDraft[];
documents?: NotificationDocument[] | null;
};
export type NotificationItem = {
id: string;
notification_type: string;
notification_datetime: string;
header: string;
text?: string | null;
date_expired?: string | null;
price?: number | string | null;
old_price?: number | string | null;
instruction_url?: string | null;
instruction_open_mode?: "new_tab" | null;
chat_message_text?: string | null;
details?: NotificationDetails | null;
priority?: number;
lifecycle_status?: "active" | "closed";
visibility?: "visible" | "hidden";
is_read?: boolean;
close_reason?: string | null;
countable?: boolean;
cta_action?: NotificationCtaAction;
};
export type NotificationList = { items: NotificationItem[] };
export type NotificationCounter = { unread_count: number };
export type NotificationActionResult =
| { action: "open_detail"; notification_id: string }
| { action: "open_url"; url: string }
| { action: "chat_message_sent"; message: Message };
export type NotificationActionState = {
notification_id: string;
lifecycle_status: "active" | "closed";
visibility: "visible" | "hidden";
is_read: boolean;
close_reason: string | null;
date_expired: string | null;
unread_count: number;
result: NotificationActionResult | null;
};
export type NotificationRealtimeEvent =
| {
type: "notification.created";
event_id: string;
occurred_at: string;
notification: NotificationItem;
unread_count: number;
}
| {
type: "notification.updated";
event_id: string;
occurred_at: string;
notification_id: string;
unread_count: number;
is_read?: boolean;
visibility?: "visible" | "hidden";
date_expired?: string | null;
close_reason?: string | null;
}
| {
type: "notification.closed";
event_id: string;
occurred_at: string;
notification_id: string;
close_reason: string;
unread_count: number;
is_read?: boolean;
visibility?: "visible" | "hidden";
date_expired?: string | null;
};
@@ -35,9 +35,9 @@ export function Button({ title, onPress, disabled, secondary, danger }: {
accessibilityRole="button"
disabled={disabled}
onPress={onPress}
style={({ focused }) => [
style={({ pressed }) => [
styles.button, secondary && styles.buttonSecondary, danger && styles.buttonDanger,
disabled && styles.buttonDisabled, focused && { borderWidth: 2, borderColor: colors.primary },
disabled && styles.buttonDisabled, pressed && { opacity: 0.8 },
]}
>
<Text style={[styles.buttonText, secondary && styles.buttonTextSecondary]}>{title}</Text>
@@ -8,6 +8,17 @@ vi.mock("expo-crypto", () => ({
randomUUID: vi.fn(() => "123e4567-e89b-42d3-a456-426614174000"),
digestStringAsync: vi.fn(async () => "stable-fingerprint"),
}));
vi.mock("expo-auth-session", () => ({
makeRedirectUri: vi.fn(() => "https://example.test/auth/callback"),
}));
vi.mock("expo-secure-store", () => ({
getItemAsync: vi.fn(async () => null),
setItemAsync: vi.fn(async () => undefined),
deleteItemAsync: vi.fn(async () => undefined),
}));
vi.mock("expo-web-browser", () => ({
maybeCompleteAuthSession: vi.fn(),
}));
vi.mock("react-native", () => ({
Platform: { OS: "web", Version: "test", constants: {} },
}));
@@ -0,0 +1,152 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../../src/auth", () => ({
getAccessToken: () => "test.jwt",
refreshTokens: vi.fn(async () => undefined),
}));
vi.mock("../../src/config", () => ({
env: { apiBaseUrl: "https://example.test" },
}));
vi.mock("../../src/services", () => ({
dialogApi: { messages: vi.fn(async () => ({ items: [], next_cursor: null })) },
}));
import {
NOTIFICATION_OUTAGE_MS,
NOTIFICATION_POLL_INTERVAL_MS,
NotificationRealtimeClient,
} from "../../src/realtime";
import {
actionDialogId,
actionUrl,
formatNotificationPrice,
notificationIcon,
notificationPalette,
typeMap,
} from "../../src/notification-presenter";
import type { NotificationType } from "../../src/types";
class MockWebSocket {
static readonly OPEN = 1;
static instances: MockWebSocket[] = [];
readonly sent: string[] = [];
readyState = MockWebSocket.OPEN;
onopen?: () => void;
onmessage?: (event: { data: string }) => void;
onclose?: (event: { code: number }) => void;
onerror?: () => void;
constructor(readonly url: string, readonly protocols: string[]) {
MockWebSocket.instances.push(this);
}
send(value: string) {
this.sent.push(value);
}
close() {}
}
describe("notification catalog presentation", () => {
it("использует neutral и bell для неизвестных значений", () => {
expect(notificationPalette("future-token")).toEqual(notificationPalette("neutral"));
expect(notificationIcon("future-icon")).toBe("bell");
expect(notificationIcon(null)).toBe("bell");
});
it("рендерит новый вид только по данным каталога", () => {
const type: NotificationType = {
code: "future_type",
label: "Новый вид",
color_token: "info",
icon_code: "news",
cta_text: "Открыть",
cta_action: "open_detail",
countable: true,
contour: "P",
button_primary: { code: "gotit", label: "Понятно" },
button_secondary: null,
};
expect(typeMap([type]).get("future_type")).toEqual(type);
expect(notificationPalette(type.color_token).accent).toBe("#2563eb");
});
it("форматирует цену в рублях и безопасно игнорирует мусор", () => {
expect(formatNotificationPrice("1500")).toContain("1 500");
expect(formatNotificationPrice("not-a-number")).toBeNull();
});
it("читает результат CTA из backend state response", () => {
const base = {
notification_id: "notification-1",
lifecycle_status: "active" as const,
visibility: "visible" as const,
is_read: true,
close_reason: null,
date_expired: null,
unread_count: 0,
};
expect(actionUrl({ ...base, result: { action: "open_url", url: "https://pay.test" } }))
.toBe("https://pay.test");
expect(actionDialogId({
...base,
result: {
action: "chat_message_sent",
message: {
message_id: "message-1",
dialog_id: "dialog-1",
sender_type: "client",
content_kind: "text",
text: "Тест",
attachments: [],
safety_status: "allowed",
delivery_status: "delivered",
created_at: "2026-07-27T12:00:00Z",
},
},
})).toBe("dialog-1");
expect(actionUrl({ ...base, result: null })).toBeUndefined();
expect(actionDialogId({ ...base, result: null })).toBeUndefined();
});
});
describe("notification realtime degradation", () => {
beforeEach(() => {
vi.useFakeTimers();
MockWebSocket.instances = [];
globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket;
});
afterEach(() => {
vi.useRealTimers();
});
it("подписывается с notifications:true", () => {
const client = new NotificationRealtimeClient(vi.fn(), vi.fn(async () => undefined), vi.fn());
client.start();
const socket = MockWebSocket.instances[0]!;
socket.onopen?.();
expect(JSON.parse(socket.sent[0]!)).toEqual({
type: "subscribe",
dialog_ids: [],
notifications: true,
});
client.stop();
});
it("после 30 секунд включает polling с интервалом 60 секунд", async () => {
const reconcile = vi.fn(async () => undefined);
const state = vi.fn();
const client = new NotificationRealtimeClient(vi.fn(), reconcile, state);
client.start();
MockWebSocket.instances[0]!.onclose?.({ code: 1006 });
await vi.advanceTimersByTimeAsync(NOTIFICATION_OUTAGE_MS);
expect(state).toHaveBeenCalledWith("polling");
expect(reconcile).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(NOTIFICATION_POLL_INTERVAL_MS);
expect(reconcile).toHaveBeenCalledTimes(2);
client.stop();
});
});
@@ -6,8 +6,7 @@
"exactOptionalPropertyTypes": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": { "@/*": ["src/*"] },
"paths": { "@/*": ["./src/*"] },
"types": ["vitest/globals"]
},
"include": ["app", "src", "tests", "app.config.ts", "expo-env.d.ts"]
@@ -3,6 +3,7 @@ import { defineConfig } from "vitest/config";
export default defineConfig({
plugins: [react()],
define: { __DEV__: false },
test: {
environment: "jsdom",
include: ["tests/unit/**/*.test.{ts,tsx}"],
+32 -1
View File
@@ -16,7 +16,9 @@ x-api-runtime: &api-runtime
env_file:
- path: ../../.env
required: false
environment: *no-sms-secrets
environment:
<<: *no-sms-secrets
NOTIFICATIONS_TOKEN_PRODUCER_TEST: ${NOTIFICATIONS_TOKEN_PRODUCER_TEST:?NOTIFICATIONS_TOKEN_PRODUCER_TEST is required}
volumes:
- type: bind
source: ${PG_CA_HOST_PATH}
@@ -251,6 +253,35 @@ services:
start_period: 10s
restart: unless-stopped
notification-expire-worker:
<<: *api-runtime
entrypoint: []
command: ["han-notification-expire-worker"]
depends_on:
api-backend: {condition: service_healthy}
healthcheck:
test: ["CMD", "python", "-c", "from pathlib import Path; assert b'han-notification-expire-worker' in Path('/proc/1/cmdline').read_bytes()"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
restart: unless-stopped
notification-draft-cleanup-worker:
<<: *api-runtime
entrypoint: []
command: ["han-notification-draft-cleanup-worker"]
depends_on:
api-backend: {condition: service_healthy}
message-safety: {condition: service_healthy}
healthcheck:
test: ["CMD", "python", "-c", "from pathlib import Path; assert b'han-notification-draft-cleanup-worker' in Path('/proc/1/cmdline').read_bytes()"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
restart: unless-stopped
bitrix-local-app:
build:
context: ../../bitrix-local-app
@@ -16,6 +16,10 @@ services:
NGINX_RATE_LIMIT_PUBLIC: ${NGINX_RATE_LIMIT_PUBLIC:-60r/m}
NGINX_RATE_LIMIT_POLLING: ${NGINX_RATE_LIMIT_POLLING:-60r/m}
NGINX_RATE_LIMIT_DOWNLOADS: ${NGINX_RATE_LIMIT_DOWNLOADS:-30r/m}
NGINX_RATE_LIMIT_NOTIFICATIONS_READ: ${NGINX_RATE_LIMIT_NOTIFICATIONS_READ:-120r/m}
NGINX_RATE_LIMIT_NOTIFICATIONS_ACTION: ${NGINX_RATE_LIMIT_NOTIFICATIONS_ACTION:-60r/m}
NGINX_RATE_LIMIT_NOTIFICATION_UPLOAD: ${NGINX_RATE_LIMIT_NOTIFICATION_UPLOAD:-20r/m}
NGINX_RATE_LIMIT_NOTIFICATIONS_PUBLIC: ${NGINX_RATE_LIMIT_NOTIFICATIONS_PUBLIC:-60r/m}
NGINX_RATE_LIMIT_BITRIX: ${NGINX_RATE_LIMIT_BITRIX:-120r/m}
NGINX_RATE_LIMIT_SMS_CALLBACK: ${NGINX_RATE_LIMIT_SMS_CALLBACK:-120r/m}
NGINX_RATE_LIMIT_WS: ${NGINX_RATE_LIMIT_WS:-30r/m}
@@ -49,6 +49,10 @@ http {
limit_req_zone $binary_remote_addr zone=public:10m rate=${NGINX_RATE_LIMIT_PUBLIC};
limit_req_zone $polling_key zone=polling:10m rate=${NGINX_RATE_LIMIT_POLLING};
limit_req_zone $binary_remote_addr zone=downloads:10m rate=${NGINX_RATE_LIMIT_DOWNLOADS};
limit_req_zone $binary_remote_addr zone=notifications_read:10m rate=${NGINX_RATE_LIMIT_NOTIFICATIONS_READ};
limit_req_zone $binary_remote_addr zone=notifications_action:10m rate=${NGINX_RATE_LIMIT_NOTIFICATIONS_ACTION};
limit_req_zone $binary_remote_addr zone=notification_upload:10m rate=${NGINX_RATE_LIMIT_NOTIFICATION_UPLOAD};
limit_req_zone $binary_remote_addr zone=notifications_public:10m rate=${NGINX_RATE_LIMIT_NOTIFICATIONS_PUBLIC};
limit_req_zone $binary_remote_addr zone=bitrix_callbacks:10m rate=${NGINX_RATE_LIMIT_BITRIX};
limit_req_zone $binary_remote_addr zone=sms_callbacks:10m rate=${NGINX_RATE_LIMIT_SMS_CALLBACK};
limit_req_zone $binary_remote_addr zone=ws_connect:10m rate=${NGINX_RATE_LIMIT_WS};
+2 -2
View File
@@ -1,7 +1,7 @@
#!/bin/sh
set -eu
required="PUBLIC_HOST NGINX_RATE_LIMIT_API NGINX_RATE_LIMIT_AUTH NGINX_RATE_LIMIT_PUBLIC NGINX_RATE_LIMIT_POLLING NGINX_RATE_LIMIT_DOWNLOADS NGINX_RATE_LIMIT_BITRIX NGINX_RATE_LIMIT_SMS_CALLBACK NGINX_RATE_LIMIT_WS NGINX_CLIENT_MAX_BODY_SIZE NGINX_MESSAGE_READ_TIMEOUT_SEC"
required="PUBLIC_HOST NGINX_RATE_LIMIT_API NGINX_RATE_LIMIT_AUTH NGINX_RATE_LIMIT_PUBLIC NGINX_RATE_LIMIT_POLLING NGINX_RATE_LIMIT_DOWNLOADS NGINX_RATE_LIMIT_NOTIFICATIONS_READ NGINX_RATE_LIMIT_NOTIFICATIONS_ACTION NGINX_RATE_LIMIT_NOTIFICATION_UPLOAD NGINX_RATE_LIMIT_NOTIFICATIONS_PUBLIC NGINX_RATE_LIMIT_BITRIX NGINX_RATE_LIMIT_SMS_CALLBACK NGINX_RATE_LIMIT_WS NGINX_CLIENT_MAX_BODY_SIZE NGINX_MESSAGE_READ_TIMEOUT_SEC"
for name in $required; do
eval "value=\${$name:-}"
if [ -z "$value" ]; then
@@ -24,7 +24,7 @@ if [ "${FRONTEND_DEV_PROXY_ENABLED:-false}" = "true" ] \
fi
umask 027
common_vars='${NGINX_RATE_LIMIT_API} ${NGINX_RATE_LIMIT_AUTH} ${NGINX_RATE_LIMIT_PUBLIC} ${NGINX_RATE_LIMIT_POLLING} ${NGINX_RATE_LIMIT_DOWNLOADS} ${NGINX_RATE_LIMIT_BITRIX} ${NGINX_RATE_LIMIT_SMS_CALLBACK} ${NGINX_RATE_LIMIT_WS} ${NGINX_CLIENT_MAX_BODY_SIZE} ${EXPO_DEV_SERVER_HOSTPORT}'
common_vars='${NGINX_RATE_LIMIT_API} ${NGINX_RATE_LIMIT_AUTH} ${NGINX_RATE_LIMIT_PUBLIC} ${NGINX_RATE_LIMIT_POLLING} ${NGINX_RATE_LIMIT_DOWNLOADS} ${NGINX_RATE_LIMIT_NOTIFICATIONS_READ} ${NGINX_RATE_LIMIT_NOTIFICATIONS_ACTION} ${NGINX_RATE_LIMIT_NOTIFICATION_UPLOAD} ${NGINX_RATE_LIMIT_NOTIFICATIONS_PUBLIC} ${NGINX_RATE_LIMIT_BITRIX} ${NGINX_RATE_LIMIT_SMS_CALLBACK} ${NGINX_RATE_LIMIT_WS} ${NGINX_CLIENT_MAX_BODY_SIZE} ${EXPO_DEV_SERVER_HOSTPORT}'
site_vars='${PUBLIC_HOST} ${NGINX_TLS_CERTIFICATE} ${NGINX_TLS_CERTIFICATE_KEY} ${NGINX_MESSAGE_READ_TIMEOUT_SEC} ${BITRIX_FRAME_ANCESTORS}'
security_vars='${NGINX_HSTS_MAX_AGE} ${S3_CONNECT_SRC}'
@@ -4,4 +4,4 @@ add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header X-Frame-Options "DENY" always;
add_header Strict-Transport-Security "max-age=${NGINX_HSTS_MAX_AGE}" always;
add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; connect-src 'self' https: wss: ${S3_CONNECT_SRC}; img-src 'self' data: blob: https:; script-src 'self'; style-src 'self' 'unsafe-inline'" always;
add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; object-src 'none'; frame-src 'none'; frame-ancestors 'none'; connect-src 'self' https: wss: ${S3_CONNECT_SRC}; img-src 'self' data: blob: https:; script-src 'self'; style-src 'self' 'unsafe-inline'" always;
@@ -86,6 +86,42 @@ server {
proxy_cache_valid 200 1h;
proxy_pass http://api_backend;
}
location = /api/v1/public/notifications {
limit_req zone=notifications_public burst=20 nodelay;
include /etc/nginx/snippets/proxy-common.conf;
proxy_cache off;
proxy_pass http://api_backend;
}
location = /api/v1/public/notification-types {
limit_req zone=notifications_public burst=20 nodelay;
include /etc/nginx/snippets/proxy-common.conf;
proxy_cache public_cache;
proxy_cache_methods GET HEAD;
proxy_cache_bypass $http_authorization;
proxy_no_cache $http_authorization $upstream_http_set_cookie;
proxy_cache_valid 200 1h;
proxy_pass http://api_backend;
}
location ~ ^/api/v1/notifications/[0-9a-fA-F-]+/documents/[0-9a-fA-F-]+/download-url$ {
limit_req zone=downloads burst=10 nodelay;
include /etc/nginx/snippets/proxy-common.conf;
proxy_pass http://api_backend;
}
location ~ ^/api/v1/notifications/[0-9a-fA-F-]+/(?:read|hide|cta|buttons/[a-z0-9_-]+)$ {
limit_req zone=notifications_action burst=20 nodelay;
include /etc/nginx/snippets/proxy-common.conf;
proxy_pass http://api_backend;
}
location ~ ^/api/v1/notifications(?:/[0-9a-fA-F-]+|/counter)?$ {
limit_req zone=notifications_read burst=30 nodelay;
include /etc/nginx/snippets/proxy-common.conf;
proxy_pass http://api_backend;
}
location ~ ^/api/v1/uploads(?:/|$) {
limit_req zone=notification_upload burst=10 nodelay;
include /etc/nginx/snippets/proxy-common.conf;
proxy_pass http://api_backend;
}
location ~ ^/api/v1/(?:documents|dialogs)/.+/download-url$ {
limit_req zone=downloads burst=10 nodelay;
include /etc/nginx/snippets/proxy-common.conf;
Binary file not shown.
@@ -0,0 +1,38 @@
import { Headphones, MessageCircle } from 'lucide-react';
import { useNavigate } from 'react-router';
import { getActiveMessages } from '../data/companyMessages';
export function QuickActions() {
const navigate = useNavigate();
const unreadCount = getActiveMessages().filter(m => m.type === 'message').length;
return (
<div className="px-4 pb-4 flex gap-2">
<button
onClick={() => navigate('/chat/1')}
className="relative flex-1 flex items-center justify-center gap-2 py-2.5 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors"
>
<span className="relative">
<MessageCircle className="w-4 h-4" />
{unreadCount > 0 && (
<>
{/* Пульсирующее кольцо */}
<span className="absolute -top-1 -right-1 w-3 h-3 rounded-full bg-red-400 opacity-75 animate-ping" />
{/* Бейдж со счётчиком */}
<span className="absolute -top-1.5 -right-1.5 min-w-[14px] h-3.5 px-0.5 flex items-center justify-center bg-red-500 text-white text-[9px] font-bold rounded-full leading-none">
{unreadCount}
</span>
</>
)}
</span>
<span className="text-sm font-medium">Чат</span>
</button>
<button className="flex-1 flex items-center justify-center gap-2 py-2.5 bg-secondary text-secondary-foreground rounded-lg hover:bg-secondary/80 transition-colors">
<Headphones className="w-4 h-4" />
<span className="text-sm">Оператор</span>
</button>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More