Добавлены уведомления
This commit is contained in:
@@ -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-конфигурация
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
## Принципы безопасности
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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}"],
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user