From 958fba5f3e21a8b03a763a961c9eba41f9fbd34a Mon Sep 17 00:00:00 2001 From: mi Date: Mon, 27 Jul 2026 17:36:53 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D1=8B=20=D1=83=D0=B2=D0=B5=D0=B4=D0=BE=D0=BC=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- architectory/arch-00-glossary.md | 16 + architectory/arch-01-system-architecture.md | 13 +- architectory/arch-02-api-contracts.md | 39 +- .../arch-03-docker-compose-blueprint.md | 12 +- architectory/arch-04-settings-and-content.md | 22 + .../arch-05-agent-development-process.md | 4 + backlog.md | 5 +- codebase/backend/.env.example | 6 + codebase/backend/api-backend/README.md | 11 + .../versions/0008_notification_center_v1.py | 651 +++++++++ .../backend/api-backend/app/integrations.py | 3 + codebase/backend/api-backend/app/main.py | 27 +- .../api-backend/app/notification_models.py | 298 +++++ .../api-backend/app/notification_routes.py | 504 +++++++ .../api-backend/app/notification_schemas.py | 72 + .../api-backend/app/notification_service.py | 1160 +++++++++++++++++ codebase/backend/api-backend/app/realtime.py | 47 +- codebase/backend/api-backend/app/services.py | 13 + codebase/backend/api-backend/app/settings.py | 3 + codebase/backend/api-backend/app/workers.py | 83 +- .../backend/api-backend/docker-compose.yml | 1 + codebase/backend/api-backend/openapi.yaml | 236 ++++ codebase/backend/api-backend/pyproject.toml | 2 + .../tests/contract/test_openapi.py | 43 + .../tests/unit/test_notifications.py | 589 +++++++++ codebase/backend/deployment/RUNBOOK.md | 6 + codebase/backend/deployment/RUNBOOK.ru.md | 6 + .../app-settings.production-like.yaml | 13 + .../frontend-test-site/app/_layout.tsx | 12 +- .../backend/frontend-test-site/app/index.tsx | 30 +- .../app/notification/[id].tsx | 286 ++++ .../app/notifications/index.tsx | 107 ++ .../frontend-test-site/app/profile.tsx | 2 +- .../frontend-test-site/src/app-context.tsx | 59 +- .../src/components/AppHeader.tsx | 48 +- .../src/components/MessageBubble.tsx | 8 +- .../src/components/NotificationCard.tsx | 98 ++ .../src/components/NotificationCarousel.tsx | 121 ++ .../src/components/QuickActions.tsx | 2 +- .../src/notification-actions.ts | 96 ++ .../src/notification-api.ts | 108 ++ .../src/notification-presenter.ts | 80 ++ .../frontend-test-site/src/realtime.ts | 120 +- .../backend/frontend-test-site/src/types.ts | 135 ++ .../backend/frontend-test-site/src/ui.tsx | 4 +- .../tests/unit/core.test.ts | 11 + .../tests/unit/notifications.test.ts | 152 +++ .../backend/frontend-test-site/tsconfig.json | 3 +- .../frontend-test-site/vitest.config.ts | 1 + .../backend/infra/compose/application.yml | 33 +- codebase/backend/nginx/docker-compose.yml | 4 + codebase/backend/nginx/nginx.conf.template | 4 + codebase/backend/nginx/scripts/entrypoint.sh | 4 +- .../templates/security-headers.conf.template | 2 +- .../nginx/templates/site-tls.conf.template | 36 + figma/Main page specification.zip | Bin 96639 -> 96955 bytes .../ATTRIBUTIONS.md | 0 figma/{ => Main page specification}/README.md | 0 .../default_shadcn_theme.css | 0 .../guidelines/Guidelines.md | 0 .../{ => Main page specification}/index.html | 0 .../package.json | 0 .../pnpm-workspace.yaml | 0 .../postcss.config.mjs | 0 .../src/app/App.tsx | 0 .../src/app/components/ChatInput.tsx | 0 .../src/app/components/HanLogo.tsx | 0 .../src/app/components/Header.tsx | 0 .../src/app/components/Notifications.tsx | 0 .../src/app/components/PopularQuestions.tsx | 0 .../src/app/components/QuickActions.tsx | 38 + .../components/figma/ImageWithFallback.tsx | 0 .../src/app/components/ui/accordion.tsx | 0 .../src/app/components/ui/alert-dialog.tsx | 0 .../src/app/components/ui/alert.tsx | 0 .../src/app/components/ui/aspect-ratio.tsx | 0 .../src/app/components/ui/avatar.tsx | 0 .../src/app/components/ui/badge.tsx | 0 .../src/app/components/ui/breadcrumb.tsx | 0 .../src/app/components/ui/button.tsx | 0 .../src/app/components/ui/calendar.tsx | 0 .../src/app/components/ui/card.tsx | 0 .../src/app/components/ui/carousel.tsx | 0 .../src/app/components/ui/chart.tsx | 0 .../src/app/components/ui/checkbox.tsx | 0 .../src/app/components/ui/collapsible.tsx | 0 .../src/app/components/ui/command.tsx | 0 .../src/app/components/ui/context-menu.tsx | 0 .../src/app/components/ui/dialog.tsx | 0 .../src/app/components/ui/drawer.tsx | 0 .../src/app/components/ui/dropdown-menu.tsx | 0 .../src/app/components/ui/form.tsx | 0 .../src/app/components/ui/hover-card.tsx | 0 .../src/app/components/ui/input-otp.tsx | 0 .../src/app/components/ui/input.tsx | 0 .../src/app/components/ui/label.tsx | 0 .../src/app/components/ui/menubar.tsx | 0 .../src/app/components/ui/navigation-menu.tsx | 0 .../src/app/components/ui/pagination.tsx | 0 .../src/app/components/ui/popover.tsx | 0 .../src/app/components/ui/progress.tsx | 0 .../src/app/components/ui/radio-group.tsx | 0 .../src/app/components/ui/resizable.tsx | 0 .../src/app/components/ui/scroll-area.tsx | 0 .../src/app/components/ui/select.tsx | 0 .../src/app/components/ui/separator.tsx | 0 .../src/app/components/ui/sheet.tsx | 0 .../src/app/components/ui/sidebar.tsx | 0 .../src/app/components/ui/skeleton.tsx | 0 .../src/app/components/ui/slider.tsx | 0 .../src/app/components/ui/sonner.tsx | 0 .../src/app/components/ui/switch.tsx | 0 .../src/app/components/ui/table.tsx | 0 .../src/app/components/ui/tabs.tsx | 0 .../src/app/components/ui/textarea.tsx | 0 .../src/app/components/ui/toggle-group.tsx | 0 .../src/app/components/ui/toggle.tsx | 0 .../src/app/components/ui/tooltip.tsx | 0 .../src/app/components/ui/use-mobile.ts | 0 .../src/app/components/ui/utils.ts | 0 .../src/app/data/companyMessages.ts | 0 .../src/app/data/session.ts | 0 .../src/app/pages/AuthConsent.tsx | 0 .../src/app/pages/AuthLoading.tsx | 0 .../src/app/pages/AuthOtp.tsx | 0 .../src/app/pages/AuthPhone.tsx | 0 .../src/app/pages/Calendar.tsx | 0 .../src/app/pages/Chat.tsx | 0 .../src/app/pages/History.tsx | 0 .../src/app/pages/Home.tsx | 0 .../src/app/pages/NotificationDetail.tsx | 0 .../src/app/pages/Profile.tsx | 0 .../src/app/pages/Root.tsx | 0 .../src/app/routes.tsx | 0 .../src/main.tsx | 0 .../src/styles/fonts.css | 0 .../src/styles/globals.css | 0 .../src/styles/index.css | 0 .../src/styles/tailwind.css | 0 .../src/styles/theme.css | 0 .../vite.config.ts | 0 figma/src/app/components/QuickActions.tsx | 23 - .../notification-requirements.md | 47 +- .../user-requirements.md | 554 ++++++++ modules/module-01-api-backend.md | 32 +- modules/module-03-nginx.md | 9 +- modules/module-07-bitrix-sync.md | 4 + releases/#0 deploy-steps.md | 6 + releases/#2 notifications deploy.md | 317 +++++ 149 files changed, 6371 insertions(+), 110 deletions(-) create mode 100644 codebase/backend/api-backend/alembic/versions/0008_notification_center_v1.py create mode 100644 codebase/backend/api-backend/app/notification_models.py create mode 100644 codebase/backend/api-backend/app/notification_routes.py create mode 100644 codebase/backend/api-backend/app/notification_schemas.py create mode 100644 codebase/backend/api-backend/app/notification_service.py create mode 100644 codebase/backend/api-backend/tests/unit/test_notifications.py create mode 100644 codebase/backend/frontend-test-site/app/notification/[id].tsx create mode 100644 codebase/backend/frontend-test-site/app/notifications/index.tsx create mode 100644 codebase/backend/frontend-test-site/src/components/NotificationCard.tsx create mode 100644 codebase/backend/frontend-test-site/src/components/NotificationCarousel.tsx create mode 100644 codebase/backend/frontend-test-site/src/notification-actions.ts create mode 100644 codebase/backend/frontend-test-site/src/notification-api.ts create mode 100644 codebase/backend/frontend-test-site/src/notification-presenter.ts create mode 100644 codebase/backend/frontend-test-site/tests/unit/notifications.test.ts rename figma/{ => Main page specification}/ATTRIBUTIONS.md (100%) rename figma/{ => Main page specification}/README.md (100%) rename figma/{ => Main page specification}/default_shadcn_theme.css (100%) rename figma/{ => Main page specification}/guidelines/Guidelines.md (100%) rename figma/{ => Main page specification}/index.html (100%) rename figma/{ => Main page specification}/package.json (100%) rename figma/{ => Main page specification}/pnpm-workspace.yaml (100%) rename figma/{ => Main page specification}/postcss.config.mjs (100%) rename figma/{ => Main page specification}/src/app/App.tsx (100%) rename figma/{ => Main page specification}/src/app/components/ChatInput.tsx (100%) rename figma/{ => Main page specification}/src/app/components/HanLogo.tsx (100%) rename figma/{ => Main page specification}/src/app/components/Header.tsx (100%) rename figma/{ => Main page specification}/src/app/components/Notifications.tsx (100%) rename figma/{ => Main page specification}/src/app/components/PopularQuestions.tsx (100%) create mode 100644 figma/Main page specification/src/app/components/QuickActions.tsx rename figma/{ => Main page specification}/src/app/components/figma/ImageWithFallback.tsx (100%) rename figma/{ => Main page specification}/src/app/components/ui/accordion.tsx (100%) rename figma/{ => Main page specification}/src/app/components/ui/alert-dialog.tsx (100%) rename figma/{ => Main page specification}/src/app/components/ui/alert.tsx (100%) rename figma/{ => Main page specification}/src/app/components/ui/aspect-ratio.tsx (100%) rename figma/{ => Main page specification}/src/app/components/ui/avatar.tsx (100%) rename figma/{ => Main page specification}/src/app/components/ui/badge.tsx (100%) rename figma/{ => Main page specification}/src/app/components/ui/breadcrumb.tsx (100%) rename figma/{ => Main page specification}/src/app/components/ui/button.tsx (100%) rename figma/{ => Main page specification}/src/app/components/ui/calendar.tsx (100%) rename figma/{ => Main page specification}/src/app/components/ui/card.tsx (100%) rename figma/{ => Main page specification}/src/app/components/ui/carousel.tsx (100%) rename figma/{ => Main page specification}/src/app/components/ui/chart.tsx (100%) rename figma/{ => Main page specification}/src/app/components/ui/checkbox.tsx (100%) rename figma/{ => Main page specification}/src/app/components/ui/collapsible.tsx (100%) rename figma/{ => Main page specification}/src/app/components/ui/command.tsx (100%) rename figma/{ => Main page specification}/src/app/components/ui/context-menu.tsx (100%) rename figma/{ => Main page specification}/src/app/components/ui/dialog.tsx (100%) rename figma/{ => Main page specification}/src/app/components/ui/drawer.tsx (100%) rename figma/{ => Main page specification}/src/app/components/ui/dropdown-menu.tsx (100%) rename figma/{ => Main page specification}/src/app/components/ui/form.tsx (100%) rename figma/{ => Main page specification}/src/app/components/ui/hover-card.tsx (100%) rename figma/{ => Main page specification}/src/app/components/ui/input-otp.tsx (100%) rename figma/{ => Main page specification}/src/app/components/ui/input.tsx (100%) rename figma/{ => Main page specification}/src/app/components/ui/label.tsx (100%) rename figma/{ => Main page specification}/src/app/components/ui/menubar.tsx (100%) rename figma/{ => Main page specification}/src/app/components/ui/navigation-menu.tsx (100%) rename figma/{ => Main page specification}/src/app/components/ui/pagination.tsx (100%) rename figma/{ => Main page specification}/src/app/components/ui/popover.tsx (100%) rename figma/{ => Main page specification}/src/app/components/ui/progress.tsx (100%) rename figma/{ => Main page specification}/src/app/components/ui/radio-group.tsx (100%) rename figma/{ => Main page specification}/src/app/components/ui/resizable.tsx (100%) rename figma/{ => Main page specification}/src/app/components/ui/scroll-area.tsx (100%) rename figma/{ => Main page specification}/src/app/components/ui/select.tsx (100%) rename figma/{ => Main page specification}/src/app/components/ui/separator.tsx (100%) rename figma/{ => Main page specification}/src/app/components/ui/sheet.tsx (100%) rename figma/{ => Main page specification}/src/app/components/ui/sidebar.tsx (100%) rename figma/{ => Main page specification}/src/app/components/ui/skeleton.tsx (100%) rename figma/{ => Main page specification}/src/app/components/ui/slider.tsx (100%) rename figma/{ => Main page specification}/src/app/components/ui/sonner.tsx (100%) rename figma/{ => Main page specification}/src/app/components/ui/switch.tsx (100%) rename figma/{ => Main page specification}/src/app/components/ui/table.tsx (100%) rename figma/{ => Main page specification}/src/app/components/ui/tabs.tsx (100%) rename figma/{ => Main page specification}/src/app/components/ui/textarea.tsx (100%) rename figma/{ => Main page specification}/src/app/components/ui/toggle-group.tsx (100%) rename figma/{ => Main page specification}/src/app/components/ui/toggle.tsx (100%) rename figma/{ => Main page specification}/src/app/components/ui/tooltip.tsx (100%) rename figma/{ => Main page specification}/src/app/components/ui/use-mobile.ts (100%) rename figma/{ => Main page specification}/src/app/components/ui/utils.ts (100%) rename figma/{ => Main page specification}/src/app/data/companyMessages.ts (100%) rename figma/{ => Main page specification}/src/app/data/session.ts (100%) rename figma/{ => Main page specification}/src/app/pages/AuthConsent.tsx (100%) rename figma/{ => Main page specification}/src/app/pages/AuthLoading.tsx (100%) rename figma/{ => Main page specification}/src/app/pages/AuthOtp.tsx (100%) rename figma/{ => Main page specification}/src/app/pages/AuthPhone.tsx (100%) rename figma/{ => Main page specification}/src/app/pages/Calendar.tsx (100%) rename figma/{ => Main page specification}/src/app/pages/Chat.tsx (100%) rename figma/{ => Main page specification}/src/app/pages/History.tsx (100%) rename figma/{ => Main page specification}/src/app/pages/Home.tsx (100%) rename figma/{ => Main page specification}/src/app/pages/NotificationDetail.tsx (100%) rename figma/{ => Main page specification}/src/app/pages/Profile.tsx (100%) rename figma/{ => Main page specification}/src/app/pages/Root.tsx (100%) rename figma/{ => Main page specification}/src/app/routes.tsx (100%) rename figma/{ => Main page specification}/src/main.tsx (100%) rename figma/{ => Main page specification}/src/styles/fonts.css (100%) rename figma/{ => Main page specification}/src/styles/globals.css (100%) rename figma/{ => Main page specification}/src/styles/index.css (100%) rename figma/{ => Main page specification}/src/styles/tailwind.css (100%) rename figma/{ => Main page specification}/src/styles/theme.css (100%) rename figma/{ => Main page specification}/vite.config.ts (100%) delete mode 100644 figma/src/app/components/QuickActions.tsx rename {busines_tasks => functional_blocks (business logic)}/notification-requirements.md (96%) create mode 100644 functional_blocks (business logic)/user-requirements.md create mode 100644 releases/#2 notifications deploy.md diff --git a/architectory/arch-00-glossary.md b/architectory/arch-00-glossary.md index 4414d0f..5419827 100644 --- a/architectory/arch-00-glossary.md +++ b/architectory/arch-00-glossary.md @@ -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-конфигурация diff --git a/architectory/arch-01-system-architecture.md b/architectory/arch-01-system-architecture.md index 921dd2c..57ff18e 100644 --- a/architectory/arch-01-system-architecture.md +++ b/architectory/arch-01-system-architecture.md @@ -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. ## Принципы безопасности diff --git a/architectory/arch-02-api-contracts.md b/architectory/arch-02-api-contracts.md index d3c9d87..ea45fd5 100644 --- a/architectory/arch-02-api-contracts.md +++ b/architectory/arch-02-api-contracts.md @@ -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_` | `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=`. +- при недоступности 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`. diff --git a/architectory/arch-03-docker-compose-blueprint.md b/architectory/arch-03-docker-compose-blueprint.md index 1f24070..72bab66 100644 --- a/architectory/arch-03-docker-compose-blueprint.md +++ b/architectory/arch-03-docker-compose-blueprint.md @@ -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. diff --git a/architectory/arch-04-settings-and-content.md b/architectory/arch-04-settings-and-content.md index 3f514c6..45bc6cf 100644 --- a/architectory/arch-04-settings-and-content.md +++ b/architectory/arch-04-settings-and-content.md @@ -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_` — индивидуальный секрет продюсера 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. diff --git a/architectory/arch-05-agent-development-process.md b/architectory/arch-05-agent-development-process.md index 639e842..0c66234 100644 --- a/architectory/arch-05-agent-development-process.md +++ b/architectory/arch-05-agent-development-process.md @@ -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 в логах; diff --git a/backlog.md b/backlog.md index 999f899..b64a6e9 100644 --- a/backlog.md +++ b/backlog.md @@ -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) \ No newline at end of file diff --git a/codebase/backend/.env.example b/codebase/backend/.env.example index b4a8880..2806737 100644 --- a/codebase/backend/.env.example +++ b/codebase/backend/.env.example @@ -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 diff --git a/codebase/backend/api-backend/README.md b/codebase/backend/api-backend/README.md index 00577e4..f6a3d67 100644 --- a/codebase/backend/api-backend/README.md +++ b/codebase/backend/api-backend/README.md @@ -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 diff --git a/codebase/backend/api-backend/alembic/versions/0008_notification_center_v1.py b/codebase/backend/api-backend/alembic/versions/0008_notification_center_v1.py new file mode 100644 index 0000000..c50c6c4 --- /dev/null +++ b/codebase/backend/api-backend/alembic/versions/0008_notification_center_v1.py @@ -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") diff --git a/codebase/backend/api-backend/app/integrations.py b/codebase/backend/api-backend/app/integrations.py index 1428b6f..dbd41a2 100644 --- a/codebase/backend/api-backend/app/integrations.py +++ b/codebase/backend/api-backend/app/integrations.py @@ -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, diff --git a/codebase/backend/api-backend/app/main.py b/codebase/backend/api-backend/app/main.py index ccbd0cd..29ace2f 100644 --- a/codebase/backend/api-backend/app/main.py +++ b/codebase/backend/api-backend/app/main.py @@ -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) diff --git a/codebase/backend/api-backend/app/notification_models.py b/codebase/backend/api-backend/app/notification_models.py new file mode 100644 index 0000000..83a3fd1 --- /dev/null +++ b/codebase/backend/api-backend/app/notification_models.py @@ -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)) diff --git a/codebase/backend/api-backend/app/notification_routes.py b/codebase/backend/api-backend/app/notification_routes.py new file mode 100644 index 0000000..f446aed --- /dev/null +++ b/codebase/backend/api-backend/app/notification_routes.py @@ -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)) diff --git a/codebase/backend/api-backend/app/notification_schemas.py b/codebase/backend/api-backend/app/notification_schemas.py new file mode 100644 index 0000000..0cdc3b7 --- /dev/null +++ b/codebase/backend/api-backend/app/notification_schemas.py @@ -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}$") diff --git a/codebase/backend/api-backend/app/notification_service.py b/codebase/backend/api-backend/app/notification_service.py new file mode 100644 index 0000000..d9eea7a --- /dev/null +++ b/codebase/backend/api-backend/app/notification_service.py @@ -0,0 +1,1160 @@ +import asyncio +import hashlib +import hmac +import json +import os +import re +import unicodedata +import uuid +from datetime import UTC, datetime, timedelta +from pathlib import PurePath +from typing import Any + +from sqlalchemy import case, func, select, update +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db import Document, UserIdentity +from app.integrations import S3Client, SafetyClient +from app.notification_models import ( + ClientDocument, + ClientUploadDraft, + GuestNotification, + Notification, + NotificationButton, + NotificationCtaAction, + NotificationDocument, + NotificationSource, + NotificationType, + uuid7, +) +from app.notification_schemas import ( + NotificationCancelRequest, + NotificationCreateRequest, + UploadCompleteRequest, + UploadInitRequest, +) +from app.realtime import RealtimeFanout +from app.services import AuditContext, DomainError, SettingsSnapshot, audit + + +def fingerprint(body: NotificationCreateRequest) -> str: + canonical = json.dumps( + body.model_dump(mode="json"), + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + ) + return hashlib.sha256(canonical.encode()).hexdigest() + + +def source_token_hash(token: str) -> str: + return hashlib.sha256(token.encode()).hexdigest() + + +async def synchronize_source_tokens(session: AsyncSession) -> None: + token = os.getenv("NOTIFICATIONS_TOKEN_PRODUCER_TEST") + if not token: + return + source = await session.scalar( + select(NotificationSource).where(NotificationSource.code == "producer_test") + ) + digest = source_token_hash(token) + if source and not hmac.compare_digest(source.token_hash, digest): + source.token_hash = digest + source.token_rotated_at = datetime.now(UTC) + await session.commit() + + +async def authenticate_source( + session: AsyncSession, authorization: str | None +) -> NotificationSource: + if not authorization or not authorization.startswith("Bearer "): + raise DomainError("unauthorized", 401, "Authentication failed") + token = authorization.removeprefix("Bearer ").strip() + if not token: + raise DomainError("unauthorized", 401, "Authentication failed") + supplied = source_token_hash(token) + sources = ( + ( + await session.execute( + select(NotificationSource).where(NotificationSource.record_status == "A") + ) + ) + .scalars() + .all() + ) + matched: NotificationSource | None = None + for source in sources: + if hmac.compare_digest(source.token_hash, supplied): + matched = source + if matched is None: + raise DomainError("unauthorized", 401, "Authentication failed") + return matched + + +async def _type(session: AsyncSession, code: str, contour: str | None = None) -> NotificationType: + query = select(NotificationType).where( + NotificationType.code == code, NotificationType.record_status == "A" + ) + if contour: + query = query.where(NotificationType.contour == contour) + item = await session.scalar(query) + if item is None: + raise DomainError( + "validation_error", + 400, + "Notification type is unavailable", + {"fields": ["notification_type"]}, + ) + return item + + +def _details_dict(body: NotificationCreateRequest) -> dict[str, Any] | None: + return ( + body.details.model_dump(mode="json", exclude_none=True, exclude_defaults=True) + if body.details + else None + ) + + +def validate_create( + body: NotificationCreateRequest, + kind: NotificationType, + action: NotificationCtaAction, +) -> None: + values: dict[str, Any] = { + "details": body.details, + "payment_url": body.payment_url, + "chat_message_text": body.chat_message_text, + } + required_fields = set(action.required_instance_fields) + errors: list[str] = [] + for field in values: + if field in required_fields and values[field] is None: + errors.append(field) + elif field not in required_fields and values[field] is not None: + errors.append(field) + details = _details_dict(body) + if "details" in required_fields and not details: + errors.append("details") + if details is not None: + for block in kind.required_detail_blocks: + if not details.get(block): + errors.append(f"details.{block}") + documents = details.get("documents") or [] + if documents and not kind.documents_allowed: + errors.append("details.documents") + if details.get("send_documents") and "send_docs" not in { + kind.button_primary_code, + kind.button_secondary_code, + }: + errors.append("details.send_documents") + if errors: + raise DomainError( + "validation_error", + 400, + "Notification fields do not match catalog rules", + {"fields": sorted(set(errors))}, + ) + + +async def create_notification( + session: AsyncSession, + body: NotificationCreateRequest, + source: NotificationSource, + s3: S3Client, + fanout: RealtimeFanout, + context: AuditContext, +) -> tuple[dict[str, Any], int]: + if body.source != source.code: + raise DomainError("forbidden", 403, "Source does not match service token") + digest = fingerprint(body) + existing = await session.scalar( + select(Notification).where( + Notification.source == source.code, + Notification.external_id == body.external_id, + ) + ) + if existing: + if hmac.compare_digest(existing.request_fingerprint, digest): + return await notification_dto(session, existing), 200 + session.add( + audit( + "notification.create_conflict", + context, + None, + "notification", + existing.id, + metadata={"source": source.code}, + outcome="failed", + ) + ) + await session.commit() + raise DomainError( + "notification_conflict", + 409, + "External id already belongs to another request", + {"notification_id": str(existing.id)}, + ) + kind = await _type(session, body.notification_type, "P") + action = await session.scalar( + select(NotificationCtaAction).where( + NotificationCtaAction.code == kind.cta_action, + NotificationCtaAction.record_status == "A", + ) + ) + if action is None: + raise DomainError("validation_error", 400, "Notification CTA is unavailable") + validate_create(body, kind, action) + if ( + await session.scalar( + select(UserIdentity.id).where( + UserIdentity.id == body.user_id, UserIdentity.record_status == "A" + ) + ) + is None + ): + raise DomainError("validation_error", 400, "User is unavailable", {"fields": ["user_id"]}) + details = _details_dict(body) + document_inputs = list(details.pop("documents", [])) if details else [] + item = Notification( + id=uuid7(), + user_id=body.user_id, + notification_type=body.notification_type, + source=body.source, + external_id=body.external_id, + request_fingerprint=digest, + notification_datetime=body.notification_datetime, + header=body.header, + text=body.text, + priority_override=body.priority_override, + date_expired=body.date_expired, + price=body.price, + old_price=body.old_price, + payment_url=str(body.payment_url) if body.payment_url else None, + details=details, + chat_message_text=body.chat_message_text, + ) + session.add(item) + for order, document_input in enumerate(document_inputs): + bucket = s3.settings.selectel_s3_bucket_documents + try: + metadata = await s3.head(bucket, document_input["object_key"]) + except Exception as exc: + raise DomainError( + "validation_error", + 400, + "Company document object is unavailable", + {"fields": [f"details.documents.{order}.object_key"]}, + ) from exc + if ( + int(metadata["ContentLength"]) != document_input["size_bytes"] + or metadata.get("ContentType") != document_input["mime_type"] + ): + raise DomainError( + "validation_error", + 400, + "Company document metadata differs", + {"fields": [f"details.documents.{order}"]}, + ) + document = await session.scalar( + select(Document).where( + Document.storage_bucket == bucket, + Document.object_key == document_input["object_key"], + ) + ) + if document is None: + document = Document( + id=uuid7(), + user_id=body.user_id, + name=document_input["title"], + mime_type=document_input["mime_type"], + size_bytes=document_input["size_bytes"], + checksum_sha256=document_input["checksum_sha256"], + storage_bucket=bucket, + object_key=document_input["object_key"], + sent_at=datetime.now(UTC), + ) + session.add(document) + elif document.user_id != body.user_id: + raise DomainError("validation_error", 400, "Company document belongs to another user") + session.add( + NotificationDocument( + id=uuid7(), + notification_id=item.id, + document_id=document.id, + sort_order=order, + ) + ) + session.add( + audit( + "notification.created", + context, + None, + "notification", + item.id, + metadata={"source": source.code, "notification_type": item.notification_type}, + ) + ) + try: + await session.commit() + except IntegrityError: + await session.rollback() + raced = await session.scalar( + select(Notification).where( + Notification.source == source.code, + Notification.external_id == body.external_id, + ) + ) + if raced and hmac.compare_digest(raced.request_fingerprint, digest): + return await notification_dto(session, raced), 200 + raise DomainError("notification_conflict", 409, "External id is already used") from None + result = await notification_dto(session, item) + unread = await unread_count(session, item.user_id, 15) + await fanout.publish_user( + item.user_id, + { + "type": "notification.created", + "occurred_at": datetime.now(UTC).isoformat(), + "notification": result, + "unread_count": unread, + }, + ) + return result, 201 + + +async def cancel_notification( + session: AsyncSession, + body: NotificationCancelRequest, + source: NotificationSource, + fanout: RealtimeFanout, + context: AuditContext, +) -> dict[str, Any]: + if body.source != source.code: + raise DomainError("forbidden", 403, "Source does not match service token") + item = await session.scalar( + select(Notification) + .where( + Notification.source == source.code, + Notification.external_id == body.external_id, + ) + .with_for_update() + ) + if item is None: + raise DomainError("not_found", 404, "Resource was not found") + changed = item.lifecycle_status != "closed" + if changed: + item.lifecycle_status = "closed" + item.close_reason = body.close_reason + item.closed_at = datetime.now(UTC) + session.add( + audit( + "notification.cancelled", + context, + None, + "notification", + item.id, + metadata={"source": source.code, "close_reason": body.close_reason}, + ) + ) + await session.commit() + result = await notification_dto(session, item) + if changed: + await fanout.publish_user( + item.user_id, + { + "type": "notification.closed", + "occurred_at": datetime.now(UTC).isoformat(), + "notification_id": str(item.id), + "close_reason": item.close_reason, + "unread_count": await unread_count(session, item.user_id, 15), + }, + ) + return result + + +async def catalog(session: AsyncSession) -> list[dict[str, Any]]: + rows = ( + ( + await session.execute( + select(NotificationType).where(NotificationType.record_status == "A") + ) + ) + .scalars() + .all() + ) + button_codes = { + code + for item in rows + for code in (item.button_primary_code, item.button_secondary_code) + if code + } + buttons = { + item.code: item + for item in ( + ( + await session.execute( + select(NotificationButton).where( + NotificationButton.code.in_(button_codes), + NotificationButton.record_status == "A", + ) + ) + ) + .scalars() + .all() + if button_codes + else [] + ) + } + + def button(code: str | None) -> dict[str, str] | None: + item = buttons.get(code or "") + return {"code": item.code, "label": item.label} if item else None + + return [ + { + "code": item.code, + "label": item.label, + "color_token": item.color_token, + "icon_code": item.icon_code, + "cta_text": item.cta_text, + "cta_action": item.cta_action, + "countable": item.countable, + "contour": item.contour, + "button_primary": button(item.button_primary_code), + "button_secondary": button(item.button_secondary_code), + } + for item in sorted(rows, key=lambda value: (value.contour, value.priority, value.code)) + ] + + +async def public_notifications(session: AsyncSession, limit: int) -> list[dict[str, Any]]: + now = datetime.now(UTC) + rows = ( + await session.execute( + select(GuestNotification, NotificationType) + .join( + NotificationType, + NotificationType.code == GuestNotification.notification_type, + ) + .where( + GuestNotification.record_status == "A", + GuestNotification.lifecycle_status == "active", + (GuestNotification.date_expired.is_(None)) | (GuestNotification.date_expired > now), + NotificationType.record_status == "A", + NotificationType.contour == "G", + ) + .order_by( + func.coalesce(GuestNotification.priority_override, NotificationType.priority), + GuestNotification.notification_datetime.desc(), + GuestNotification.id.desc(), + ) + .limit(limit) + ) + ).all() + return [ + { + "id": item.id, + "notification_type": item.notification_type, + "notification_datetime": item.notification_datetime, + "header": item.header, + "text": item.text, + "price": item.price, + "old_price": item.old_price, + "instruction_url": item.instruction_url, + "instruction_open_mode": "new_tab" if item.instruction_url else None, + "chat_message_text": item.chat_message_text, + } + for item, _kind in rows + ] + + +def _active_notification_query(user_id: uuid.UUID): + now = datetime.now(UTC) + return ( + select(Notification, NotificationType) + .join(NotificationType, NotificationType.code == Notification.notification_type) + .where( + Notification.user_id == user_id, + Notification.record_status == "A", + Notification.lifecycle_status == "active", + (Notification.date_expired.is_(None)) | (Notification.date_expired > now), + NotificationType.contour == "P", + ) + ) + + +async def list_notifications( + session: AsyncSession, user_id: uuid.UUID, place: str, limit: int +) -> list[dict[str, Any]]: + query = _active_notification_query(user_id) + priority = func.coalesce(Notification.priority_override, NotificationType.priority) + if place == "home": + query = query.where(Notification.visibility == "visible").order_by( + priority, Notification.notification_datetime.desc(), Notification.id.desc() + ) + else: + query = query.order_by( + priority, + case((Notification.is_read.is_(False), 0), else_=1), + Notification.notification_datetime.desc(), + Notification.id.desc(), + ) + rows = (await session.execute(query.limit(limit))).all() + return [await notification_dto(session, item, kind) for item, kind in rows] + + +async def unread_count(session: AsyncSession, user_id: uuid.UUID, limit: int) -> int: + rows = await list_notifications(session, user_id, "center", limit) + return sum(1 for item in rows if item["countable"] and not item["is_read"]) + + +async def owned_notification( + session: AsyncSession, + user_id: uuid.UUID, + notification_id: uuid.UUID, + *, + action: bool = False, +) -> tuple[Notification, NotificationType]: + statement = ( + select(Notification, NotificationType) + .join(NotificationType, NotificationType.code == Notification.notification_type) + .where( + Notification.id == notification_id, + Notification.user_id == user_id, + Notification.record_status == "A", + ) + ) + if action: + statement = statement.with_for_update() + row = (await session.execute(statement)).one_or_none() + if row is None: + raise DomainError("not_found", 404, "Resource was not found") + item, kind = row + if item.lifecycle_status == "closed" or ( + item.date_expired is not None and item.date_expired <= datetime.now(UTC) + ): + if action: + raise DomainError("notification_closed", 409, "Notification is closed") + raise DomainError("not_found", 404, "Resource was not found") + return item, kind + + +async def notification_dto( + session: AsyncSession, item: Notification, kind: NotificationType | None = None +) -> dict[str, Any]: + kind = kind or await _type(session, item.notification_type) + details = dict(item.details or {}) + links = ( + await session.execute( + select(NotificationDocument, Document) + .join(Document, Document.id == NotificationDocument.document_id) + .where( + NotificationDocument.notification_id == item.id, + NotificationDocument.record_status == "A", + Document.record_status == "A", + ) + .order_by(NotificationDocument.sort_order, NotificationDocument.id) + ) + ).all() + if links: + details["documents"] = [ + { + "document_id": document.id, + "title": document.name, + "mime_type": document.mime_type, + "size_bytes": document.size_bytes, + } + for _link, document in links + ] + if details.get("send_documents"): + drafts = ( + ( + await session.execute( + select(ClientUploadDraft).where( + ClientUploadDraft.user_id == item.user_id, + ClientUploadDraft.context_type == "notification", + ClientUploadDraft.context_id == item.id, + ClientUploadDraft.state == "draft", + ) + ) + ) + .scalars() + .all() + ) + details["pending_documents"] = [upload_dto(draft) for draft in drafts] + return { + "id": item.id, + "notification_type": item.notification_type, + "notification_datetime": item.notification_datetime, + "header": item.header, + "text": item.text, + "priority": item.priority_override if item.priority_override is not None else kind.priority, + "date_expired": item.date_expired, + "price": item.price, + "old_price": item.old_price, + "details": details or None, + "lifecycle_status": item.lifecycle_status, + "visibility": item.visibility, + "is_read": item.is_read, + "close_reason": item.close_reason, + "countable": kind.countable, + "cta_action": kind.cta_action, + } + + +def _apply_hidden_ttl( + item: Notification, kind: NotificationType, snapshot: SettingsSnapshot +) -> None: + item.visibility = "hidden" + if item.date_expired is None: + days = kind.hidden_ttl_days or snapshot.integer("notification.hidden.default_ttl_days") + item.date_expired = datetime.now(UTC) + timedelta(days=days) + + +async def state_response( + session: AsyncSession, item: Notification, center_limit: int, result: Any = None +) -> dict[str, Any]: + return { + "notification_id": item.id, + "lifecycle_status": item.lifecycle_status, + "visibility": item.visibility, + "is_read": item.is_read, + "close_reason": item.close_reason, + "date_expired": item.date_expired, + "unread_count": await unread_count(session, item.user_id, center_limit), + "result": result, + } + + +async def apply_read_or_hide( + session: AsyncSession, + user_id: uuid.UUID, + notification_id: uuid.UUID, + operation: str, + snapshot: SettingsSnapshot, + fanout: RealtimeFanout, + context: AuditContext, +) -> dict[str, Any]: + item, kind = await owned_notification(session, user_id, notification_id, action=True) + center_limit = snapshot.integer("notification.center.max_items") + changed: dict[str, Any] = {} + if operation == "read" and not item.is_read: + item.is_read = True + changed["is_read"] = True + if operation == "hide" and item.visibility != "hidden": + _apply_hidden_ttl(item, kind, snapshot) + changed["visibility"] = "hidden" + changed["date_expired"] = item.date_expired + if changed: + session.add( + audit( + f"notification.{operation}", + context, + user_id, + "notification", + item.id, + ) + ) + await session.commit() + changed["unread_count"] = await unread_count(session, user_id, center_limit) + await fanout.publish_user( + user_id, + { + "type": "notification.updated", + "occurred_at": datetime.now(UTC).isoformat(), + "notification_id": str(item.id), + **changed, + }, + ) + return await state_response(session, item, center_limit) + + +async def press_button( + session: AsyncSession, + user_id: uuid.UUID, + notification_id: uuid.UUID, + code: str, + snapshot: SettingsSnapshot, + fanout: RealtimeFanout, + context: AuditContext, +) -> dict[str, Any]: + item, kind = await owned_notification(session, user_id, notification_id, action=True) + if code not in {kind.button_primary_code, kind.button_secondary_code}: + raise DomainError("button_not_allowed", 422, "Button is not allowed") + button = await session.scalar( + select(NotificationButton).where( + NotificationButton.code == code, NotificationButton.record_status == "A" + ) + ) + if button is None: + raise DomainError("button_not_allowed", 422, "Button is not allowed") + submission_id: uuid.UUID | None = None + submitted = 0 + if button.submits_documents: + drafts = ( + ( + await session.execute( + select(ClientUploadDraft) + .where( + ClientUploadDraft.user_id == user_id, + ClientUploadDraft.context_type == "notification", + ClientUploadDraft.context_id == item.id, + ClientUploadDraft.state == "draft", + ClientUploadDraft.scan_status == "clean", + ) + .with_for_update() + ) + ) + .scalars() + .all() + ) + if not drafts: + raise DomainError("validation_error", 400, "At least one clean document is required") + submission_id = uuid7() + for draft in drafts: + session.add( + ClientDocument( + id=uuid7(), + user_id=user_id, + context_type=draft.context_type, + context_id=draft.context_id, + submission_id=submission_id, + source_draft_id=draft.id, + original_file_name=draft.original_file_name, + safe_file_name=draft.safe_file_name, + mime_type=draft.mime_type, + size_bytes=draft.size_bytes, + checksum_sha256=draft.checksum_sha256 or "", + storage_bucket=draft.storage_bucket, + object_key=draft.object_key, + submitted_at=datetime.now(UTC), + ) + ) + draft.state = "submitted" + draft.submission_id = submission_id + submitted = len(drafts) + if button.sets_hidden: + if button.applies_hidden_ttl: + _apply_hidden_ttl(item, kind, snapshot) + else: + item.visibility = "hidden" + if button.close_reason: + item.lifecycle_status = "closed" + item.close_reason = button.close_reason + item.closed_at = datetime.now(UTC) + session.add( + audit( + "notification.button_pressed", + context, + user_id, + "notification", + item.id, + metadata={ + "button_code": code, + "submission_id": str(submission_id) if submission_id else None, + "submitted_count": submitted, + }, + ) + ) + await session.commit() + unread = await unread_count(session, user_id, snapshot.integer("notification.center.max_items")) + event_type = ( + "notification.closed" if item.lifecycle_status == "closed" else "notification.updated" + ) + await fanout.publish_user( + user_id, + { + "type": event_type, + "occurred_at": datetime.now(UTC).isoformat(), + "notification_id": str(item.id), + "close_reason": item.close_reason, + "visibility": item.visibility, + "date_expired": item.date_expired, + "unread_count": unread, + }, + ) + return await state_response(session, item, snapshot.integer("notification.center.max_items")) + + +async def invoke_cta_state( + session: AsyncSession, + user_id: uuid.UUID, + notification_id: uuid.UUID, + snapshot: SettingsSnapshot, + fanout: RealtimeFanout, + context: AuditContext, + *, + chat_result: Any = None, +) -> tuple[Notification, NotificationType, dict[str, Any]]: + item, kind = await owned_notification(session, user_id, notification_id, action=True) + item.is_read = True + result: dict[str, Any] + if kind.cta_action == "open_detail": + result = {"action": "open_detail", "notification_id": str(item.id)} + elif kind.cta_action == "open_payment_url": + result = {"action": "open_url", "url": item.payment_url} + elif kind.cta_action == "send_chat_message": + if chat_result is None: + result = {"action": "send_chat_message", "text": item.chat_message_text} + else: + result = {"action": "chat_message_sent", "message": chat_result} + else: + raise DomainError("validation_error", 400, "CTA is unavailable for personal contour") + if kind.cta_sets_hidden: + item.visibility = "hidden" + if kind.cta_close_reason: + item.lifecycle_status = "closed" + item.close_reason = kind.cta_close_reason + item.closed_at = datetime.now(UTC) + session.add( + audit( + "notification.cta_invoked", + context, + user_id, + "notification", + item.id, + metadata={"cta_action": kind.cta_action}, + ) + ) + await session.commit() + unread = await unread_count(session, user_id, snapshot.integer("notification.center.max_items")) + await fanout.publish_user( + user_id, + { + "type": ( + "notification.closed" + if item.lifecycle_status == "closed" + else "notification.updated" + ), + "occurred_at": datetime.now(UTC).isoformat(), + "notification_id": str(item.id), + "is_read": True, + "visibility": item.visibility, + "close_reason": item.close_reason, + "unread_count": unread, + }, + ) + return ( + item, + kind, + await state_response( + session, + item, + snapshot.integer("notification.center.max_items"), + result, + ), + ) + + +async def document_download( + session: AsyncSession, + user_id: uuid.UUID, + notification_id: uuid.UUID, + document_id: uuid.UUID, + snapshot: SettingsSnapshot, + s3: S3Client, + fanout: RealtimeFanout, + context: AuditContext, +) -> dict[str, Any]: + item, kind = await owned_notification(session, user_id, notification_id, action=True) + row = ( + await session.execute( + select(NotificationDocument, Document) + .join(Document, Document.id == NotificationDocument.document_id) + .where( + NotificationDocument.notification_id == item.id, + NotificationDocument.document_id == document_id, + NotificationDocument.record_status == "A", + Document.user_id == user_id, + Document.record_status == "A", + ) + .with_for_update() + ) + ).one_or_none() + if row is None: + raise DomainError("not_found", 404, "Resource was not found") + link, document = row + first_download = ( + await session.scalar( + select(func.count(NotificationDocument.id)).where( + NotificationDocument.notification_id == item.id, + NotificationDocument.download_url_issued_at.is_not(None), + ) + ) + == 0 + ) + link.download_url_issued_at = link.download_url_issued_at or datetime.now(UTC) + changed = False + if first_download and kind.hide_on_document_download: + item.is_read = True + _apply_hidden_ttl(item, kind, snapshot) + changed = True + session.add( + audit( + "notification.document.download_url_issued", + context, + user_id, + "document", + document.id, + metadata={"notification_id": str(item.id), "expires_in_seconds": 300}, + ) + ) + await session.commit() + if changed: + await fanout.publish_user( + user_id, + { + "type": "notification.updated", + "occurred_at": datetime.now(UTC).isoformat(), + "notification_id": str(item.id), + "is_read": True, + "visibility": item.visibility, + "date_expired": item.date_expired, + "unread_count": await unread_count( + session, user_id, snapshot.integer("notification.center.max_items") + ), + }, + ) + return { + "download_url": await s3.presign_get(document.storage_bucket, document.object_key), + "expires_at": datetime.now(UTC) + timedelta(seconds=300), + } + + +async def init_upload( + session: AsyncSession, + user_id: uuid.UUID, + body: UploadInitRequest, + snapshot: SettingsSnapshot, + s3: S3Client, +) -> dict[str, Any]: + item, _kind = await owned_notification(session, user_id, body.context_id) + if not (item.details or {}).get("send_documents"): + raise DomainError("validation_error", 400, "Notification does not accept documents") + current = await session.scalar( + select(func.count(ClientUploadDraft.id)).where( + ClientUploadDraft.user_id == user_id, + ClientUploadDraft.context_type == body.context_type, + ClientUploadDraft.context_id == body.context_id, + ClientUploadDraft.state == "draft", + ) + ) + if int(current or 0) >= snapshot.integer("notification.documents.max_files"): + raise DomainError("attachment_invalid", 400, "Document limit reached") + extension = PurePath(body.file_name).suffix.lower().lstrip(".") + if ( + extension not in snapshot.strings("chat.attachments.allowed_extensions") + or extension in snapshot.strings("chat.attachments.disallowed_extensions") + or body.mime_type not in snapshot.strings("chat.attachments.allowed_mime_types") + or body.size_bytes > snapshot.integer("chat.attachments.max_size_mb") * 1024 * 1024 + ): + raise DomainError("attachment_invalid", 400, "File type or size is not allowed") + draft_id = uuid7() + key = f"quarantine/users/{user_id}/uploads/{draft_id}" + ttl = snapshot.integer("chat.attachments.presigned_upload_ttl_seconds") + expires = datetime.now(UTC) + timedelta(seconds=ttl) + safe_name = re.sub(r"[^A-Za-z0-9._-]", "_", unicodedata.normalize("NFKC", body.file_name)) + draft = ClientUploadDraft( + id=draft_id, + user_id=user_id, + context_type=body.context_type, + context_id=body.context_id, + original_file_name=body.file_name, + safe_file_name=safe_name, + mime_type=body.mime_type, + size_bytes=body.size_bytes, + storage_bucket=s3.settings.selectel_s3_bucket_quarantine, + object_key=key, + quarantine_object_key=key, + upload_expires_at=expires, + ) + session.add(draft) + await session.commit() + return { + "draft_id": draft.id, + "upload_url": await s3.presign_put(key, body.mime_type, ttl), + "upload_headers": {"Content-Type": body.mime_type}, + "expires_at": expires, + } + + +async def complete_upload( + session: AsyncSession, + user_id: uuid.UUID, + draft_id: uuid.UUID, + body: UploadCompleteRequest, + s3: S3Client, + safety: SafetyClient, + request_id: str, +) -> dict[str, Any]: + draft = await _owned_draft(session, user_id, draft_id) + checksum = body.checksum.removeprefix("sha256:") + if draft.completed_at: + if draft.checksum_sha256 != checksum: + raise DomainError("resource_state_conflict", 409, "Checksum changed") + return upload_dto(draft) + try: + metadata = await s3.head(draft.storage_bucket, draft.object_key) + except Exception as exc: + raise DomainError("dependency_unavailable", 503, "Object storage unavailable") from exc + if ( + int(metadata["ContentLength"]) != draft.size_bytes + or metadata.get("ContentType") != draft.mime_type + ): + raise DomainError("attachment_invalid", 400, "Uploaded metadata differs") + draft.checksum_sha256 = checksum + verdict = await safety.check( + { + "message_id": str(draft.id), + "content_kind": "file", + "text": "", + "attachment": { + "attachment_id": str(draft.id), + "quarantine_object_key": draft.quarantine_object_key, + "checksum": body.checksum, + "mime_type": draft.mime_type, + "size_bytes": draft.size_bytes, + }, + }, + request_id, + ) + if verdict["_status"] == 203: + deadline = datetime.now(UTC) + timedelta( + seconds=safety.settings.message_safety_task_poll_max_sec + ) + while verdict["_status"] == 203 and datetime.now(UTC) < deadline: + await asyncio.sleep(safety.settings.message_safety_task_poll_interval_sec) + verdict = await safety.poll(verdict["task_id"], request_id) + if verdict["_status"] == 200: + destination = ( + f"attachments/users/{user_id}/{draft.context_type}/{draft.context_id}/{draft.id}" + ) + await s3.promote(draft.quarantine_object_key or draft.object_key, destination) + draft.storage_bucket = s3.settings.selectel_s3_bucket_attachments + draft.object_key = destination + draft.quarantine_object_key = None + draft.scan_status = "clean" + elif verdict["_status"] == 403 or ( + verdict["_status"] == 400 and verdict.get("verdict") == "deny" + ): + if draft.quarantine_object_key: + await s3.delete_quarantine(draft.quarantine_object_key) + draft.scan_status = "infected" + else: + draft.scan_status = "failed" + draft.completed_at = datetime.now(UTC) + await session.commit() + return upload_dto(draft) + + +async def _owned_draft( + session: AsyncSession, user_id: uuid.UUID, draft_id: uuid.UUID +) -> ClientUploadDraft: + draft = await session.scalar( + select(ClientUploadDraft).where( + ClientUploadDraft.id == draft_id, + ClientUploadDraft.user_id == user_id, + ) + ) + if draft is None: + raise DomainError("not_found", 404, "Resource was not found") + return draft + + +def upload_dto(draft: ClientUploadDraft) -> dict[str, Any]: + return { + "draft_id": draft.id, + "context_type": draft.context_type, + "context_id": draft.context_id, + "title": draft.safe_file_name, + "mime_type": draft.mime_type, + "size_bytes": draft.size_bytes, + "scan_status": draft.scan_status, + "state": draft.state, + } + + +async def list_uploads( + session: AsyncSession, user_id: uuid.UUID, context_type: str, context_id: uuid.UUID +) -> list[dict[str, Any]]: + await owned_notification(session, user_id, context_id) + drafts = ( + ( + await session.execute( + select(ClientUploadDraft) + .where( + ClientUploadDraft.user_id == user_id, + ClientUploadDraft.context_type == context_type, + ClientUploadDraft.context_id == context_id, + ClientUploadDraft.state == "draft", + ) + .order_by(ClientUploadDraft.created_at, ClientUploadDraft.id) + ) + ) + .scalars() + .all() + ) + return [upload_dto(item) for item in drafts] + + +async def discard_upload( + session: AsyncSession, user_id: uuid.UUID, draft_id: uuid.UUID, s3: S3Client +) -> None: + draft = await _owned_draft(session, user_id, draft_id) + if draft.state == "submitted": + raise DomainError("resource_state_conflict", 409, "Document is already submitted") + if draft.state != "discarded": + if draft.quarantine_object_key: + await s3.delete_quarantine(draft.quarantine_object_key) + elif draft.storage_bucket and draft.object_key: + await s3.delete(draft.storage_bucket, draft.object_key) + draft.state = "discarded" + await session.commit() + + +async def expire_notifications(session: AsyncSession) -> tuple[int, int]: + now = datetime.now(UTC) + locked = await session.scalar(select(func.pg_try_advisory_xact_lock(0x48414E4E4F544946))) + if not locked: + return 0, 0 + personal = await session.execute( + update(Notification) + .where( + Notification.record_status == "A", + Notification.lifecycle_status == "active", + Notification.date_expired <= now, + ) + .values(lifecycle_status="closed", close_reason="expired", closed_at=now) + ) + guest = await session.execute( + update(GuestNotification) + .where( + GuestNotification.record_status == "A", + GuestNotification.lifecycle_status == "active", + GuestNotification.date_expired <= now, + ) + .values(lifecycle_status="closed", closed_at=now) + ) + personal_count = int(getattr(personal, "rowcount", 0) or 0) + guest_count = int(getattr(guest, "rowcount", 0) or 0) + session.add( + audit( + "notification.expired_batch", + AuditContext( + request_id=f"notification-expire-{uuid7()}", + trace_id=str(uuid7()), + ux_session_id=None, + user_agent_hash=None, + client_ip=None, + ), + None, + metadata={ + "personal_count": personal_count, + "guest_count": guest_count, + }, + ) + ) + await session.commit() + return personal_count, guest_count diff --git a/codebase/backend/api-backend/app/realtime.py b/codebase/backend/api-backend/app/realtime.py index d7e23ac..3f0ddc4 100644 --- a/codebase/backend/api-backend/app/realtime.py +++ b/codebase/backend/api-backend/app/realtime.py @@ -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: diff --git a/codebase/backend/api-backend/app/services.py b/codebase/backend/api-backend/app/services.py index 8758004..dfd1086 100644 --- a/codebase/backend/api-backend/app/services.py +++ b/codebase/backend/api-backend/app/services.py @@ -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 diff --git a/codebase/backend/api-backend/app/settings.py b/codebase/backend/api-backend/app/settings.py index 995e263..3893a03 100644 --- a/codebase/backend/api-backend/app/settings.py +++ b/codebase/backend/api-backend/app/settings.py @@ -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: diff --git a/codebase/backend/api-backend/app/workers.py b/codebase/backend/api-backend/app/workers.py index bffc2e0..9d17d4b 100644 --- a/codebase/backend/api-backend/app/workers.py +++ b/codebase/backend/api-backend/app/workers.py @@ -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()) diff --git a/codebase/backend/api-backend/docker-compose.yml b/codebase/backend/api-backend/docker-compose.yml index 6782a88..40b73e2 100644 --- a/codebase/backend/api-backend/docker-compose.yml +++ b/codebase/backend/api-backend/docker-compose.yml @@ -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 diff --git a/codebase/backend/api-backend/openapi.yaml b/codebase/backend/api-backend/openapi.yaml index 393f423..d7320f3 100644 --- a/codebase/backend/api-backend/openapi.yaml +++ b/codebase/backend/api-backend/openapi.yaml @@ -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 diff --git a/codebase/backend/api-backend/pyproject.toml b/codebase/backend/api-backend/pyproject.toml index ec20781..166a292 100644 --- a/codebase/backend/api-backend/pyproject.toml +++ b/codebase/backend/api-backend/pyproject.toml @@ -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"] diff --git a/codebase/backend/api-backend/tests/contract/test_openapi.py b/codebase/backend/api-backend/tests/contract/test_openapi.py index c18bfb3..ae1479d 100644 --- a/codebase/backend/api-backend/tests/contract/test_openapi.py +++ b/codebase/backend/api-backend/tests/contract/test_openapi.py @@ -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("=") diff --git a/codebase/backend/api-backend/tests/unit/test_notifications.py b/codebase/backend/api-backend/tests/unit/test_notifications.py new file mode 100644 index 0000000..405dcce --- /dev/null +++ b/codebase/backend/api-backend/tests/unit/test_notifications.py @@ -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() diff --git a/codebase/backend/deployment/RUNBOOK.md b/codebase/backend/deployment/RUNBOOK.md index a161155..b6253e1 100644 --- a/codebase/backend/deployment/RUNBOOK.md +++ b/codebase/backend/deployment/RUNBOOK.md @@ -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 diff --git a/codebase/backend/deployment/RUNBOOK.ru.md b/codebase/backend/deployment/RUNBOOK.ru.md index e5687f0..806902b 100644 --- a/codebase/backend/deployment/RUNBOOK.ru.md +++ b/codebase/backend/deployment/RUNBOOK.ru.md @@ -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 — наблюдаемость diff --git a/codebase/backend/deployment/app-settings.production-like.yaml b/codebase/backend/deployment/app-settings.production-like.yaml index 533df48..932d12d 100644 --- a/codebase/backend/deployment/app-settings.production-like.yaml +++ b/codebase/backend/deployment/app-settings.production-like.yaml @@ -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} diff --git a/codebase/backend/frontend-test-site/app/_layout.tsx b/codebase/backend/frontend-test-site/app/_layout.tsx index 14a662b..60440b6 100644 --- a/codebase/backend/frontend-test-site/app/_layout.tsx +++ b/codebase/backend/frontend-test-site/app/_layout.tsx @@ -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 diff --git a/codebase/backend/frontend-test-site/app/index.tsx b/codebase/backend/frontend-test-site/app/index.tsx index 6081acf..e06cdf4 100644 --- a/codebase/backend/frontend-test-site/app/index.tsx +++ b/codebase/backend/frontend-test-site/app/index.tsx @@ -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(); const [sending, setSending] = useState(false); + const [afterNotificationAuth, setAfterNotificationAuth] = useState<(() => Promise) | 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 ? ( {welcome} ) : null} + { + setAfterNotificationAuth(afterAuth ? () => afterAuth : undefined); + setConsentOpen(true); + }} + /> { setMessage(text); void send(text); }} /> - {sendError && ( + {Boolean(sendError) && ( @@ -174,6 +197,7 @@ export default function HomeScreen() { onCancel={() => { setConsentOpen(false); setPending(null); + setAfterNotificationAuth(undefined); clearPendingTextIntent(); }} /> diff --git a/codebase/backend/frontend-test-site/app/notification/[id].tsx b/codebase/backend/frontend-test-site/app/notification/[id].tsx new file mode 100644 index 0000000..d88a875 --- /dev/null +++ b/codebase/backend/frontend-test-site/app/notification/[id].tsx @@ -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(); + 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 ( + + router.replace("/notifications")} /> + + Для просмотра уведомления требуется авторизация. + + + + + ); +} diff --git a/figma/src/app/components/figma/ImageWithFallback.tsx b/figma/Main page specification/src/app/components/figma/ImageWithFallback.tsx similarity index 100% rename from figma/src/app/components/figma/ImageWithFallback.tsx rename to figma/Main page specification/src/app/components/figma/ImageWithFallback.tsx diff --git a/figma/src/app/components/ui/accordion.tsx b/figma/Main page specification/src/app/components/ui/accordion.tsx similarity index 100% rename from figma/src/app/components/ui/accordion.tsx rename to figma/Main page specification/src/app/components/ui/accordion.tsx diff --git a/figma/src/app/components/ui/alert-dialog.tsx b/figma/Main page specification/src/app/components/ui/alert-dialog.tsx similarity index 100% rename from figma/src/app/components/ui/alert-dialog.tsx rename to figma/Main page specification/src/app/components/ui/alert-dialog.tsx diff --git a/figma/src/app/components/ui/alert.tsx b/figma/Main page specification/src/app/components/ui/alert.tsx similarity index 100% rename from figma/src/app/components/ui/alert.tsx rename to figma/Main page specification/src/app/components/ui/alert.tsx diff --git a/figma/src/app/components/ui/aspect-ratio.tsx b/figma/Main page specification/src/app/components/ui/aspect-ratio.tsx similarity index 100% rename from figma/src/app/components/ui/aspect-ratio.tsx rename to figma/Main page specification/src/app/components/ui/aspect-ratio.tsx diff --git a/figma/src/app/components/ui/avatar.tsx b/figma/Main page specification/src/app/components/ui/avatar.tsx similarity index 100% rename from figma/src/app/components/ui/avatar.tsx rename to figma/Main page specification/src/app/components/ui/avatar.tsx diff --git a/figma/src/app/components/ui/badge.tsx b/figma/Main page specification/src/app/components/ui/badge.tsx similarity index 100% rename from figma/src/app/components/ui/badge.tsx rename to figma/Main page specification/src/app/components/ui/badge.tsx diff --git a/figma/src/app/components/ui/breadcrumb.tsx b/figma/Main page specification/src/app/components/ui/breadcrumb.tsx similarity index 100% rename from figma/src/app/components/ui/breadcrumb.tsx rename to figma/Main page specification/src/app/components/ui/breadcrumb.tsx diff --git a/figma/src/app/components/ui/button.tsx b/figma/Main page specification/src/app/components/ui/button.tsx similarity index 100% rename from figma/src/app/components/ui/button.tsx rename to figma/Main page specification/src/app/components/ui/button.tsx diff --git a/figma/src/app/components/ui/calendar.tsx b/figma/Main page specification/src/app/components/ui/calendar.tsx similarity index 100% rename from figma/src/app/components/ui/calendar.tsx rename to figma/Main page specification/src/app/components/ui/calendar.tsx diff --git a/figma/src/app/components/ui/card.tsx b/figma/Main page specification/src/app/components/ui/card.tsx similarity index 100% rename from figma/src/app/components/ui/card.tsx rename to figma/Main page specification/src/app/components/ui/card.tsx diff --git a/figma/src/app/components/ui/carousel.tsx b/figma/Main page specification/src/app/components/ui/carousel.tsx similarity index 100% rename from figma/src/app/components/ui/carousel.tsx rename to figma/Main page specification/src/app/components/ui/carousel.tsx diff --git a/figma/src/app/components/ui/chart.tsx b/figma/Main page specification/src/app/components/ui/chart.tsx similarity index 100% rename from figma/src/app/components/ui/chart.tsx rename to figma/Main page specification/src/app/components/ui/chart.tsx diff --git a/figma/src/app/components/ui/checkbox.tsx b/figma/Main page specification/src/app/components/ui/checkbox.tsx similarity index 100% rename from figma/src/app/components/ui/checkbox.tsx rename to figma/Main page specification/src/app/components/ui/checkbox.tsx diff --git a/figma/src/app/components/ui/collapsible.tsx b/figma/Main page specification/src/app/components/ui/collapsible.tsx similarity index 100% rename from figma/src/app/components/ui/collapsible.tsx rename to figma/Main page specification/src/app/components/ui/collapsible.tsx diff --git a/figma/src/app/components/ui/command.tsx b/figma/Main page specification/src/app/components/ui/command.tsx similarity index 100% rename from figma/src/app/components/ui/command.tsx rename to figma/Main page specification/src/app/components/ui/command.tsx diff --git a/figma/src/app/components/ui/context-menu.tsx b/figma/Main page specification/src/app/components/ui/context-menu.tsx similarity index 100% rename from figma/src/app/components/ui/context-menu.tsx rename to figma/Main page specification/src/app/components/ui/context-menu.tsx diff --git a/figma/src/app/components/ui/dialog.tsx b/figma/Main page specification/src/app/components/ui/dialog.tsx similarity index 100% rename from figma/src/app/components/ui/dialog.tsx rename to figma/Main page specification/src/app/components/ui/dialog.tsx diff --git a/figma/src/app/components/ui/drawer.tsx b/figma/Main page specification/src/app/components/ui/drawer.tsx similarity index 100% rename from figma/src/app/components/ui/drawer.tsx rename to figma/Main page specification/src/app/components/ui/drawer.tsx diff --git a/figma/src/app/components/ui/dropdown-menu.tsx b/figma/Main page specification/src/app/components/ui/dropdown-menu.tsx similarity index 100% rename from figma/src/app/components/ui/dropdown-menu.tsx rename to figma/Main page specification/src/app/components/ui/dropdown-menu.tsx diff --git a/figma/src/app/components/ui/form.tsx b/figma/Main page specification/src/app/components/ui/form.tsx similarity index 100% rename from figma/src/app/components/ui/form.tsx rename to figma/Main page specification/src/app/components/ui/form.tsx diff --git a/figma/src/app/components/ui/hover-card.tsx b/figma/Main page specification/src/app/components/ui/hover-card.tsx similarity index 100% rename from figma/src/app/components/ui/hover-card.tsx rename to figma/Main page specification/src/app/components/ui/hover-card.tsx diff --git a/figma/src/app/components/ui/input-otp.tsx b/figma/Main page specification/src/app/components/ui/input-otp.tsx similarity index 100% rename from figma/src/app/components/ui/input-otp.tsx rename to figma/Main page specification/src/app/components/ui/input-otp.tsx diff --git a/figma/src/app/components/ui/input.tsx b/figma/Main page specification/src/app/components/ui/input.tsx similarity index 100% rename from figma/src/app/components/ui/input.tsx rename to figma/Main page specification/src/app/components/ui/input.tsx diff --git a/figma/src/app/components/ui/label.tsx b/figma/Main page specification/src/app/components/ui/label.tsx similarity index 100% rename from figma/src/app/components/ui/label.tsx rename to figma/Main page specification/src/app/components/ui/label.tsx diff --git a/figma/src/app/components/ui/menubar.tsx b/figma/Main page specification/src/app/components/ui/menubar.tsx similarity index 100% rename from figma/src/app/components/ui/menubar.tsx rename to figma/Main page specification/src/app/components/ui/menubar.tsx diff --git a/figma/src/app/components/ui/navigation-menu.tsx b/figma/Main page specification/src/app/components/ui/navigation-menu.tsx similarity index 100% rename from figma/src/app/components/ui/navigation-menu.tsx rename to figma/Main page specification/src/app/components/ui/navigation-menu.tsx diff --git a/figma/src/app/components/ui/pagination.tsx b/figma/Main page specification/src/app/components/ui/pagination.tsx similarity index 100% rename from figma/src/app/components/ui/pagination.tsx rename to figma/Main page specification/src/app/components/ui/pagination.tsx diff --git a/figma/src/app/components/ui/popover.tsx b/figma/Main page specification/src/app/components/ui/popover.tsx similarity index 100% rename from figma/src/app/components/ui/popover.tsx rename to figma/Main page specification/src/app/components/ui/popover.tsx diff --git a/figma/src/app/components/ui/progress.tsx b/figma/Main page specification/src/app/components/ui/progress.tsx similarity index 100% rename from figma/src/app/components/ui/progress.tsx rename to figma/Main page specification/src/app/components/ui/progress.tsx diff --git a/figma/src/app/components/ui/radio-group.tsx b/figma/Main page specification/src/app/components/ui/radio-group.tsx similarity index 100% rename from figma/src/app/components/ui/radio-group.tsx rename to figma/Main page specification/src/app/components/ui/radio-group.tsx diff --git a/figma/src/app/components/ui/resizable.tsx b/figma/Main page specification/src/app/components/ui/resizable.tsx similarity index 100% rename from figma/src/app/components/ui/resizable.tsx rename to figma/Main page specification/src/app/components/ui/resizable.tsx diff --git a/figma/src/app/components/ui/scroll-area.tsx b/figma/Main page specification/src/app/components/ui/scroll-area.tsx similarity index 100% rename from figma/src/app/components/ui/scroll-area.tsx rename to figma/Main page specification/src/app/components/ui/scroll-area.tsx diff --git a/figma/src/app/components/ui/select.tsx b/figma/Main page specification/src/app/components/ui/select.tsx similarity index 100% rename from figma/src/app/components/ui/select.tsx rename to figma/Main page specification/src/app/components/ui/select.tsx diff --git a/figma/src/app/components/ui/separator.tsx b/figma/Main page specification/src/app/components/ui/separator.tsx similarity index 100% rename from figma/src/app/components/ui/separator.tsx rename to figma/Main page specification/src/app/components/ui/separator.tsx diff --git a/figma/src/app/components/ui/sheet.tsx b/figma/Main page specification/src/app/components/ui/sheet.tsx similarity index 100% rename from figma/src/app/components/ui/sheet.tsx rename to figma/Main page specification/src/app/components/ui/sheet.tsx diff --git a/figma/src/app/components/ui/sidebar.tsx b/figma/Main page specification/src/app/components/ui/sidebar.tsx similarity index 100% rename from figma/src/app/components/ui/sidebar.tsx rename to figma/Main page specification/src/app/components/ui/sidebar.tsx diff --git a/figma/src/app/components/ui/skeleton.tsx b/figma/Main page specification/src/app/components/ui/skeleton.tsx similarity index 100% rename from figma/src/app/components/ui/skeleton.tsx rename to figma/Main page specification/src/app/components/ui/skeleton.tsx diff --git a/figma/src/app/components/ui/slider.tsx b/figma/Main page specification/src/app/components/ui/slider.tsx similarity index 100% rename from figma/src/app/components/ui/slider.tsx rename to figma/Main page specification/src/app/components/ui/slider.tsx diff --git a/figma/src/app/components/ui/sonner.tsx b/figma/Main page specification/src/app/components/ui/sonner.tsx similarity index 100% rename from figma/src/app/components/ui/sonner.tsx rename to figma/Main page specification/src/app/components/ui/sonner.tsx diff --git a/figma/src/app/components/ui/switch.tsx b/figma/Main page specification/src/app/components/ui/switch.tsx similarity index 100% rename from figma/src/app/components/ui/switch.tsx rename to figma/Main page specification/src/app/components/ui/switch.tsx diff --git a/figma/src/app/components/ui/table.tsx b/figma/Main page specification/src/app/components/ui/table.tsx similarity index 100% rename from figma/src/app/components/ui/table.tsx rename to figma/Main page specification/src/app/components/ui/table.tsx diff --git a/figma/src/app/components/ui/tabs.tsx b/figma/Main page specification/src/app/components/ui/tabs.tsx similarity index 100% rename from figma/src/app/components/ui/tabs.tsx rename to figma/Main page specification/src/app/components/ui/tabs.tsx diff --git a/figma/src/app/components/ui/textarea.tsx b/figma/Main page specification/src/app/components/ui/textarea.tsx similarity index 100% rename from figma/src/app/components/ui/textarea.tsx rename to figma/Main page specification/src/app/components/ui/textarea.tsx diff --git a/figma/src/app/components/ui/toggle-group.tsx b/figma/Main page specification/src/app/components/ui/toggle-group.tsx similarity index 100% rename from figma/src/app/components/ui/toggle-group.tsx rename to figma/Main page specification/src/app/components/ui/toggle-group.tsx diff --git a/figma/src/app/components/ui/toggle.tsx b/figma/Main page specification/src/app/components/ui/toggle.tsx similarity index 100% rename from figma/src/app/components/ui/toggle.tsx rename to figma/Main page specification/src/app/components/ui/toggle.tsx diff --git a/figma/src/app/components/ui/tooltip.tsx b/figma/Main page specification/src/app/components/ui/tooltip.tsx similarity index 100% rename from figma/src/app/components/ui/tooltip.tsx rename to figma/Main page specification/src/app/components/ui/tooltip.tsx diff --git a/figma/src/app/components/ui/use-mobile.ts b/figma/Main page specification/src/app/components/ui/use-mobile.ts similarity index 100% rename from figma/src/app/components/ui/use-mobile.ts rename to figma/Main page specification/src/app/components/ui/use-mobile.ts diff --git a/figma/src/app/components/ui/utils.ts b/figma/Main page specification/src/app/components/ui/utils.ts similarity index 100% rename from figma/src/app/components/ui/utils.ts rename to figma/Main page specification/src/app/components/ui/utils.ts diff --git a/figma/src/app/data/companyMessages.ts b/figma/Main page specification/src/app/data/companyMessages.ts similarity index 100% rename from figma/src/app/data/companyMessages.ts rename to figma/Main page specification/src/app/data/companyMessages.ts diff --git a/figma/src/app/data/session.ts b/figma/Main page specification/src/app/data/session.ts similarity index 100% rename from figma/src/app/data/session.ts rename to figma/Main page specification/src/app/data/session.ts diff --git a/figma/src/app/pages/AuthConsent.tsx b/figma/Main page specification/src/app/pages/AuthConsent.tsx similarity index 100% rename from figma/src/app/pages/AuthConsent.tsx rename to figma/Main page specification/src/app/pages/AuthConsent.tsx diff --git a/figma/src/app/pages/AuthLoading.tsx b/figma/Main page specification/src/app/pages/AuthLoading.tsx similarity index 100% rename from figma/src/app/pages/AuthLoading.tsx rename to figma/Main page specification/src/app/pages/AuthLoading.tsx diff --git a/figma/src/app/pages/AuthOtp.tsx b/figma/Main page specification/src/app/pages/AuthOtp.tsx similarity index 100% rename from figma/src/app/pages/AuthOtp.tsx rename to figma/Main page specification/src/app/pages/AuthOtp.tsx diff --git a/figma/src/app/pages/AuthPhone.tsx b/figma/Main page specification/src/app/pages/AuthPhone.tsx similarity index 100% rename from figma/src/app/pages/AuthPhone.tsx rename to figma/Main page specification/src/app/pages/AuthPhone.tsx diff --git a/figma/src/app/pages/Calendar.tsx b/figma/Main page specification/src/app/pages/Calendar.tsx similarity index 100% rename from figma/src/app/pages/Calendar.tsx rename to figma/Main page specification/src/app/pages/Calendar.tsx diff --git a/figma/src/app/pages/Chat.tsx b/figma/Main page specification/src/app/pages/Chat.tsx similarity index 100% rename from figma/src/app/pages/Chat.tsx rename to figma/Main page specification/src/app/pages/Chat.tsx diff --git a/figma/src/app/pages/History.tsx b/figma/Main page specification/src/app/pages/History.tsx similarity index 100% rename from figma/src/app/pages/History.tsx rename to figma/Main page specification/src/app/pages/History.tsx diff --git a/figma/src/app/pages/Home.tsx b/figma/Main page specification/src/app/pages/Home.tsx similarity index 100% rename from figma/src/app/pages/Home.tsx rename to figma/Main page specification/src/app/pages/Home.tsx diff --git a/figma/src/app/pages/NotificationDetail.tsx b/figma/Main page specification/src/app/pages/NotificationDetail.tsx similarity index 100% rename from figma/src/app/pages/NotificationDetail.tsx rename to figma/Main page specification/src/app/pages/NotificationDetail.tsx diff --git a/figma/src/app/pages/Profile.tsx b/figma/Main page specification/src/app/pages/Profile.tsx similarity index 100% rename from figma/src/app/pages/Profile.tsx rename to figma/Main page specification/src/app/pages/Profile.tsx diff --git a/figma/src/app/pages/Root.tsx b/figma/Main page specification/src/app/pages/Root.tsx similarity index 100% rename from figma/src/app/pages/Root.tsx rename to figma/Main page specification/src/app/pages/Root.tsx diff --git a/figma/src/app/routes.tsx b/figma/Main page specification/src/app/routes.tsx similarity index 100% rename from figma/src/app/routes.tsx rename to figma/Main page specification/src/app/routes.tsx diff --git a/figma/src/main.tsx b/figma/Main page specification/src/main.tsx similarity index 100% rename from figma/src/main.tsx rename to figma/Main page specification/src/main.tsx diff --git a/figma/src/styles/fonts.css b/figma/Main page specification/src/styles/fonts.css similarity index 100% rename from figma/src/styles/fonts.css rename to figma/Main page specification/src/styles/fonts.css diff --git a/figma/src/styles/globals.css b/figma/Main page specification/src/styles/globals.css similarity index 100% rename from figma/src/styles/globals.css rename to figma/Main page specification/src/styles/globals.css diff --git a/figma/src/styles/index.css b/figma/Main page specification/src/styles/index.css similarity index 100% rename from figma/src/styles/index.css rename to figma/Main page specification/src/styles/index.css diff --git a/figma/src/styles/tailwind.css b/figma/Main page specification/src/styles/tailwind.css similarity index 100% rename from figma/src/styles/tailwind.css rename to figma/Main page specification/src/styles/tailwind.css diff --git a/figma/src/styles/theme.css b/figma/Main page specification/src/styles/theme.css similarity index 100% rename from figma/src/styles/theme.css rename to figma/Main page specification/src/styles/theme.css diff --git a/figma/vite.config.ts b/figma/Main page specification/vite.config.ts similarity index 100% rename from figma/vite.config.ts rename to figma/Main page specification/vite.config.ts diff --git a/figma/src/app/components/QuickActions.tsx b/figma/src/app/components/QuickActions.tsx deleted file mode 100644 index ac2bbf2..0000000 --- a/figma/src/app/components/QuickActions.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { Headphones, MessageCircle } from 'lucide-react'; -import { useNavigate } from 'react-router'; - -export function QuickActions() { - const navigate = useNavigate(); - - return ( -
- - - -
- ); -} diff --git a/busines_tasks/notification-requirements.md b/functional_blocks (business logic)/notification-requirements.md similarity index 96% rename from busines_tasks/notification-requirements.md rename to functional_blocks (business logic)/notification-requirements.md index becb021..c58b7b9 100644 --- a/busines_tasks/notification-requirements.md +++ b/functional_blocks (business logic)/notification-requirements.md @@ -45,9 +45,9 @@ ### 3.1. В scope - Контуры G и P (§2); персональный жизненный цикл: `lifecycle_status` / `visibility` / `is_read` / `close_reason` + `record_status` (arch-05). -- UI: карусель на главной, Центр уведомлений, деталка, экран инструкции по установке. +- UI: карусель на главной, Центр уведомлений, деталка; инструкция по установке всегда открывается внешней страницей в новой вкладке. - **Каталог видов уведомлений как данные** (§5.2): справочник видов и три реестра (механики CTA, кнопки, палитра) в БД; добавление вида на существующей механике CTA не требует изменений кода. Публичный id записи — **UUID v7** (генерация в приложении). -- Клиентские действия (ЛК): крестик, CTA, деталка, кнопки деталки из справочника (§5.4), оплата, приём предложения, документы. +- Клиентские действия (ЛК): крестик, CTA, деталка, кнопки деталки из справочника (§5.4), оплата (вид CTA), приём предложения (вид CTA), загрузить документ, скачать документ. - Отправка документов клиентом: черновики → «Отправить документы» → реестр → `sync_queue` (**механизм общий, переиспользуемый другими фичами**, §6.5, §10.6–§10.8). - Internal API: **Create**, **Cancel** (без upsert / без update content) — §7.4. - Константы `notification.*` в `app_settings`; правила вложений — reuse `chat.attachments.*`. @@ -86,7 +86,7 @@ | Главная | — | Карусель (лимит/сортировка §6.1) | | Центр | — | ЛК — список; гость — auth-gate + «Авторизоваться» | | Деталка | — | Для видов с CTA `open_detail`; блоки и кнопки — из каталога (§5.6.1, §6.3) | -| Инструкция по установке | — | Модальное окно со страницей инструкции для `install_app` (§6.4) | +| Инструкция по установке | — | Переход на страницу другого сайта для `install_app` (§6.4) | Визуал видов — по Figma. Figma не канон поведения. Палитра `color_token` (§5.2.1) и набор `icon_code` (§5.2) сверяются с Figma: имена токенов и кодов фиксируются в БД, значения цветов и сами SVG — в теме и коде фронта. @@ -201,7 +201,7 @@ Seed палитры: - Кнопка с непустым `close_reason` закрывает уведомление; `visibility` при этом не важен, так как закрытая запись не отображается нигде. - `later` не меняет ничего, кроме закрытия экрана деталки: `is_read` уже выставлен по CTA. -- TTL берётся из `notification_types.hidden_ttl_days`, при `NULL` — из `notification.hidden.default_ttl_days`. Заданный продюсером `date_expired` не перезаписывается. +- TTL берётся из `notification_types.hidden_ttl_days`, при `NULL` — из `notification.hidden.default_ttl_days`, и применяется только если `date_expired` отсутствует. Заданный продюсером `date_expired` не перезаписывается. - `send_docs` переносит черновики клиента в реестр (§6.5.2) и закрывает уведомление. Если когда-нибудь понадобится отправка документов без закрытия — это новая кнопка в справочнике, а не флаг у вида. Кнопки привязываются к виду **двумя слотами** в строке `notification_types` (§5.2): @@ -378,11 +378,10 @@ Seed `notification.*` в `app_settings`: | `notification.carousel.autoplay_interval_ms` | Интервал автопрокрутки | `5000` | **да** | | `notification.hidden.default_ttl_days` | Скрытие без `date_expired` → now + N дней, если у вида не задан `hidden_ttl_days` | `3` | нет | | `notification.documents.max_files` | Максимум файлов в одной отправке клиента | `10` | нет | -| `notification.instruction.allowed_hosts` | Хосты, допустимые к показу инструкции в модалке (string_list) | origin приложения | нет | | `notification.expire_job.run_at` | Время ежедневного джоба закрытия (UTC, `HH:MM`) | `00:01` | нет | | `notification.upload_draft.ttl_days` | TTL неотправленных черновиков документов | `7` | нет | -Лимиты применяются на сервере, поэтому `max_items` клиенту не публикуются. Allow-list инструкций клиенту не публикуется: режим показа определяет бэкенд (§6.4). +Лимиты применяются на сервере, поэтому `max_items` клиенту не публикуются. Настройки allow-list/режима показа инструкции отсутствуют: `instruction_url` всегда открывается в новой вкладке (§6.4). **Ключей с кодом вида в имени в `app_settings` быть не должно.** Настройка, специфичная для вида, — это колонка справочника (`hidden_ttl_days`), иначе добавление вида требовало бы новых ключей настроек, то есть перестало бы быть операцией над данными. @@ -436,18 +435,7 @@ Seed `notification.*` в `app_settings`: ### 6.4. Экран инструкции по установке -Единственный сценарий с внешней страницей. Применяется только к механике `install_app_prompt` (контур G) и только на шаге 3 адаптивного CTA (§5.9). - -**Режим показа определяет бэкенд** и возвращает в поле `instruction_render_mode`: - -| Значение | Когда | Поведение фронта | -|---|---|---| -| `modal` | Хост `instruction_url` входит в `notification.instruction.allowed_hosts` | Открыть модалку с `iframe` | -| `external` | Хост не в allow-list | Открыть в новой вкладке, модалку не показывать | - -Причина: внешняя страница может запретить встраивание через `X-Frame-Options` / `frame-ancestors`, и это не детектируется из JS — пользователь получил бы пустое белое окно. В allow-list попадают только страницы, для которых встраивание проверено; практически это собственный origin приложения. CSP `frame-src` должен соответствовать allow-list (§14). - -В модалке всегда доступны закрытие и явная ссылка «Открыть в новой вкладке». Кнопок действий на этом экране нет: инструкция ничего не меняет в состоянии, а в контуре G состояние и негде хранить. +Единственный сценарий с внешней страницей. Применяется только к механике `install_app_prompt` (контур G) и только на шаге 3 адаптивного CTA (§5.9). `instruction_url` **всегда** открывается в новой вкладке браузера (`target=_blank` с защитой `noopener,noreferrer` или эквивалент платформы). Модалка, webview и iframe запрещены независимо от хоста. ### 6.4.1. CTA рекламных видов @@ -466,7 +454,7 @@ Seed `notification.*` в `app_settings`: - Каждый документ регистрируется строкой в таблице `documents` и связывается с уведомлением через `notification_documents` (§10.5). Это делает будущий раздел профиля «Документы» сборником по всем каналам без миграции файлов. - Продюсер передаёт документы в блоке `details.documents[]` метаданными (`object_key`, `title`, `mime_type`, `size_bytes`, `checksum_sha256`); `api-backend` при Create регистрирует их в `documents` / `notification_documents` и **заменяет блок на представление для чтения** с `document_id` (§5.6.1). - Скачивание — короткий presigned GET через `.../download-url` по `document_id`, с обязательным audit-событием (§12); URL в логи и audit не пишется. Постоянных ссылок на файлы не существует. -- У вида с `hide_on_document_download=true` **факт выдачи download-url по первому документу** трактуется как получение (проверить реальное скачивание технически невозможно). Эффекты — §7.1. +- У вида с `hide_on_document_download=true` **первый успешный запрос download-url для любого связанного документа** считается началом скачивания и атомарно скрывает уведомление. Неважно, какой документ из списка скачан первым; последующие запросы по этому или другому документу состояние не меняют. Presigned GET не позволяет надёжно наблюдать получение последнего байта без проксирования файла, поэтому граница «скачивание» в API — успешная выдача URL после owner-check и audit. Эффекты — §7.1. #### 6.5.2. Документы клиента (отправка) — общий механизм @@ -525,7 +513,7 @@ CTA = первичное действие карточки: кнопка CTA н | CTA вида **с деталкой** | — | `true` | **не меняется** | не меняется | | CTA вида **без деталки** | `notification_types.cta_sets_hidden` / `cta_close_reason` | `true` | `hidden`, если `cta_sets_hidden` | `closed` + `cta_close_reason`, если задан | | Нажата кнопка деталки | `notification_buttons` (§5.4) | — (уже `true`) | `hidden` + TTL, если `sets_hidden` | `closed` + `close_reason` кнопки, если задан | -| Выдан download-url по первому документу | `notification_types.hide_on_document_download` | `true` | `hidden` + TTL | — | +| Впервые успешно выдан download-url по любому связанному документу | `notification_types.hide_on_document_download` | `true` | `hidden` + TTL | — | Пояснения: @@ -533,7 +521,7 @@ CTA = первичное действие карточки: кнопка CTA н - **У вида с деталкой CTA не меняет `visibility`.** Открытие деталки — это чтение, а не решение по задаче: решение принимает пользователь кнопкой. Поэтому `cta_sets_hidden=true` и непустой `cta_close_reason` у вида с `cta_action='open_detail'` запрещены CHECK-ограничением (§10.2). Практическое следствие: `news` и `status_changed` уходят с главной только по кнопке «Понятно», а не по факту открытия. - **`payment_pending` по CTA `visibility` не меняет** (`cta_sets_hidden=false`). Клиент может уйти на платёжную страницу и не заплатить; карточка обязана остаться на главной. Она уходит только по подтверждению оплаты (Cancel с `close_reason='paid'`) или по `date_expired`. - Открытие деталки из Центра по тапу строки = CTA → всегда `is_read=true`. -- TTL при скрытии = `notification_types.hidden_ttl_days`, иначе `notification.hidden.default_ttl_days`; заданный продюсером `date_expired` никогда не перезаписывается. +- TTL при скрытии = `notification_types.hidden_ttl_days`, иначе `notification.hidden.default_ttl_days`, и применяется **только при `date_expired IS NULL`**. Если `date_expired` уже задан продюсером, скрытие меняет `visibility`, но дату не пересчитывает и не перезаписывает. - Синхронизация между устройствами обеспечивается тем, что все переходы выполняет бэкенд и рассылает WS-события (§9.4). ### 7.2. Механика `send_chat_message` (виды `ads_*` / `promo_*`) @@ -658,6 +646,7 @@ Fingerprint запроса — канонизированный хэш знач - `source` в теле запроса обязан совпадать с `source`, к которому привязан токен; иначе `403 forbidden`; - Cancel разрешён только по записям своего `source`; чужой ключ неотличим от несуществующего и даёт `404 not_found`; - добавление продюсера — строка в `notification_sources` и одна env-переменная вида `NOTIFICATIONS_TOKEN_`; ротация токена не затрагивает остальных. +- seed содержит активный `source='producer_test'` только для smoke Internal API. Его secret передаётся как `NOTIFICATIONS_TOKEN_PRODUCER_TEST`, а в `notification_sources.token_hash` хранится только hash; использовать этот source для бизнес-событий запрещено. - Callers: сервисы приватной сети облака. Из интернета путь недоступен (nginx отдаёт `404`). - Операции: @@ -899,7 +888,7 @@ WHERE record_status='A' AND lifecycle_status='active' AND date_expired IS NOT NU Unique active `(notification_id, document_id)`. Сами файлы описываются существующей таблицей `documents` (module-01 §9.9): при Create `api-backend` проверяет объект в `han-chat-documents` через HeadObject и создаёт строку `documents`, либо переиспользует существующую по unique `(storage_bucket, object_key)`. -`download_url_issued_at` по первому документу — триггерное условие эффекта при `hide_on_document_download=true` (§7.1). +Первое заполнение `download_url_issued_at` у **любого** документа связи — триггерное условие эффекта при `hide_on_document_download=true` (§7.1). Обновление выполняется атомарно с audit и скрытием уведомления; уже заполненная связь или ранее скачанный другой документ повторного эффекта не создают. Блок `details.documents[]` при чтении собирается из этой связи, а не из сохранённого продюсером JSON: иначе `title` и состав документов расходились бы с реестром после административной правки. @@ -1041,10 +1030,10 @@ Audit-события пишутся в `audit_events` (module-01 §9.14) по п | Компонент | Изменение | |---|---| -| **Frontend** | Один источник (G или P); карусель; Центр и бейдж от бэкенда; **рендеринг карточек и кнопок по каталогу видов, без ветвлений по коду вида**; **значения токенов палитры в теме (светлая/тёмная) и набор SVG под `icon_code`**, оба с фолбэком при неизвестном имени; деталка как последовательность блоков `details`; адаптивный `install_app` с экраном инструкции; черновики документов; Чат/Оператор; подписка `notifications` в WS; CSP `frame-src` под allow-list инструкций | +| **Frontend** | Один источник (G или P); карусель; Центр и бейдж от бэкенда; **рендеринг карточек и кнопок по каталогу видов, без ветвлений по коду вида**; **значения токенов палитры в теме (светлая/тёмная) и набор SVG под `icon_code`**, оба с фолбэком при неизвестном имени; деталка как последовательность блоков `details`; адаптивный `install_app` с инструкцией только в новой вкладке; черновики документов; Чат/Оператор; подписка `notifications` в WS; CSP `frame-src 'none'` | | **api-backend** | Модель G+P; public/JWT/internal API; **валидация и эффекты, выводимые из справочников** (`cta_action`, кнопки, `required_detail_blocks`); валидатор схемы `details`; ежедневный джоб закрытия и очистка черновиков; WS-подписка и события; общий механизм загрузки файлов; регистрация документов компании в `documents` | | **App DB** | Таблицы §10; seed четырёх справочников каталога (виды, механики CTA, кнопки, палитра) и справочника источников; триггер валидации `guest_notifications`; триггер `document.client_uploaded`; новые ключи `app_settings`; GRANT для `bitrix_sync_user` на `client_documents` | -| **nginx** | `/internal/notifications/*` не наружу; rate limit новых зон; SPA-роут `/notification/{uuid}`; заголовок CSP с `frame-src` | +| **nginx** | `/internal/notifications/*` не наружу; rate limit новых зон; SPA-роут `/notification/{uuid}`; CSP `frame-src 'none'`, так как instruction не встраивается | | **Redis/WS** | События `notification.*` в канал `han:rt:user:{user_id}` | | **message-safety** | Изменений контракта нет: черновики проверяются как вложения чата | | **bitrix-sync** | Новый `task_type` в контракте очереди; обработка — вне scope (§3.2) | @@ -1065,10 +1054,10 @@ Audit-события пишутся в `audit_events` (module-01 §9.14) по п | `arch-01-system-architecture.md` | Уведомления как домен `api-backend`; поток «продюсер → Internal Create → WS → клиент»; поток отправки документов клиентом; фиксация назначения `han-chat-documents` (документы компании из всех каналов) | | `arch-02-api-contracts.md` | Пути §9.1–§9.3, включая публичный каталог видов и единый эндпоинт нажатия кнопки; расширение `subscribe` полем `notifications` и три новых WS-события; новые коды ошибок `notification_conflict`, `notification_closed`, `button_not_allowed`, `attachment_invalid`; `task_type` `document.client_uploaded`; правило дедупликации по бизнес-ключу как исключение из общей политики `Idempotency-Key`; **модель «токен на продюсера»** — первый internal-эндпоинт с несколькими токенами и разрешением идентичности вызывающего по токену | | `arch-03-docker-compose-blueprint.md` | Env `NOTIFICATIONS_TOKEN_` — по одной переменной на продюсера; GRANT `bitrix_sync_user` на `client_documents` | -| `arch-04-settings-and-content.md` | Seed `notification.*` (§5.10) с флагами `is_public`; новые ключи `rate_limit.notification*`; фиксация `notification.instruction.allowed_hosts` как источника CSP `frame-src`; **правило «настройка, специфичная для вида уведомления, — колонка справочника, а не ключ `app_settings`»** (§5.10) | +| `arch-04-settings-and-content.md` | Seed `notification.*` (§5.10) с флагами `is_public`; новые ключи `rate_limit.notification*`; отсутствие allow-list/iframe-настройки instruction; **правило «настройка, специфичная для вида уведомления, — колонка справочника, а не ключ `app_settings`»** (§5.10) | | `arch-05-agent-development-process.md` | Уточнить разграничение `record_status` (только административное удаление) и доменного `lifecycle_status` (бизнес-завершение). Отметить, что запрет физического удаления прикладных строк придётся пересматривать при выносе истории в озеро данных (§13) | | `module-01-api-backend.md` | Таблицы §10 в §9; новые S3-префиксы в §14; триггер в §16; событие подписки в §17; новые джобы в списке workers; лимиты в §19; env в §18.3 | -| `module-03-nginx.md` | Запрет `/internal/notifications/*` снаружи; зоны rate limit; CSP `frame-src` | +| `module-03-nginx.md` | Запрет `/internal/notifications/*` снаружи; зоны rate limit; CSP `frame-src 'none'` | | `module-07-bitrix-sync.md` | Контракт задачи `document.client_uploaded`: payload, dedup, ожидаемое поведение при включении сервиса | --- @@ -1083,11 +1072,11 @@ Audit-события пишутся в `audit_events` (module-01 §9.14) по п 6. **Новый вид уведомления добавляется без правки кода:** добавление строки в `notification_types` со ссылкой на существующий `cta_action` и привязкой кнопок делает вид полностью работоспособным — создание через Internal Create, корректная карточка, деталка, кнопки и переходы жизненного цикла — при нулевых изменениях в бэкенде и фронте. Проверяется приёмочным тестом на заведомо новом виде, отсутствующем в seed. 7. **CTA у вида с деталкой не меняет `visibility`:** после открытия деталки и возврата назад карточка остаётся на главной; уходит она только по кнопке «Понятно» или «Готово». 8. Оформление: неизвестный или пустой `icon_code` рисуется иконкой по умолчанию, неизвестный `color_token` — токеном `neutral`; карточка остаётся работоспособной. Токен, отсутствующий в `notification_color_tokens`, в вид не сохраняется — FK не даёт. Значений цвета в БД нет. -9. Экран инструкции: хост из allow-list открывается в модалке, прочие — в новой вкладке; запись с `install_app_prompt` без `instruction_url` не создаётся. +9. Инструкция: любой допустимый `instruction_url` всегда открывается в новой вкладке; модалка/iframe не используются; запись с `install_app_prompt` без `instruction_url` не создаётся. 10. Internal только Create/Cancel; повтор Create с тем же ключом и тем же телом → `200` с существующей записью; с другим телом → `409 notification_conflict`; Cancel идемпотентен; повторное использование `(source, external_id)` невозможно даже после закрытия; продюсер не может создать или отменить запись с чужим `source`. 11. Ежедневный джоб закрывает истёкшие; до его прогона истёкшие уже не показываются за счёт фильтра выборки. 12. Документы клиента: черновик переживает выход из карточки и виден в блоке `pending_documents[]` при возврате с любого устройства; черновик можно удалить; кнопка «Отправить документы» переносит все чистые черновики в реестр, ставит задачи `document.client_uploaded` в `sync_queue` и закрывает уведомление с `docs_submitted`. -13. Документы компании лежат в `han-chat-documents`, зарегистрированы в `documents`, скачиваются presigned GET с audit-событием. **Постоянных ссылок на файлы в `details` нет** — блок `documents[]` при чтении содержит `document_id`, а не URL. +13. Документы компании лежат в `han-chat-documents`, зарегистрированы в `documents`, скачиваются presigned GET с audit-событием. Первое скачивание любого связанного документа скрывает уведомление один раз; TTL выставляет `date_expired` только при её отсутствии. **Постоянных ссылок на файлы в `details` нет** — блок `documents[]` при чтении содержит `document_id`, а не URL. 14. `details` принимается только по схеме §5.6.1: неизвестный блок и попытка передать `pending_documents` в Create → `400 validation_error`. 15. Оператор — только из `operator.call.phone`. 16. Все константы — из `app_settings`; идентификаторы UUID v7; чужое/закрытое → `404`, действие по закрытому → `409 notification_closed`, кнопка не от этого вида → `422 button_not_allowed`. @@ -1117,7 +1106,7 @@ Audit-события пишутся в `audit_events` (module-01 §9.14) по п | D11 | Один механизм дедупликации — бизнес-ключ `(source, external_id)`; повтор с тем же телом идемпотентен; ключ не переиспользуется | §7.4 | | D12 | IDOR: единый `404` на чужое и закрытое | §9.5 | | D13 | Кнопки деталки — справочник с фиксированными эффектами (`done`, `later`, `gotit`, `send_docs`); привязка к виду — **два слота в строке вида, а не связующая таблица**: слот даёт инварианты в `CHECK` и визуальный вес кнопки; **у вида с деталкой `visibility` и завершение управляются только кнопками, CTA их не меняет**; единый эндпоинт нажатия вместо эндпоинта на кнопку | §5.2, §5.4, §6.3, §7.1, §9.1, §10.2.3 | -| D14 | Внешняя страница — только инструкция по установке (`install_app_prompt`), с allow-list хостов и режимом `modal`/`external`. Рекламные виды промежуточных экранов не имеют: CTA сразу отправляет сообщение в чат | §6.4, §6.4.1, §7.2 | +| D14 | Внешняя страница — только инструкция по установке (`install_app_prompt`) и всегда новая вкладка; modal/iframe отсутствуют. Рекламные виды промежуточных экранов не имеют: CTA сразу отправляет сообщение в чат | §6.4, §6.4.1, §7.2 | | D15 | Отправка документов через черновики и кнопку «Отправить документы»; механизм общий и переиспользуемый | §6.5, §10.6–§10.8 | | D16 | `payment_pending` не скрывается с главной по CTA | §7.1 | | D17 | Бейдж считается по окну Центра | §6.2 | diff --git a/functional_blocks (business logic)/user-requirements.md b/functional_blocks (business logic)/user-requirements.md new file mode 100644 index 0000000..0f1c276 --- /dev/null +++ b/functional_blocks (business logic)/user-requirements.md @@ -0,0 +1,554 @@ +# Бизнес-постановка: Пользователь (User) + +**Статус:** v1 — консолидация принятых решений из `HAN_chat_specification` (`arch-00`…`arch-05`, `module-01`, `module-08`); открытые вопросы зафиксированы в §17 +**Продукт:** HAN Chat (клиентское приложение + `api-backend` + Keycloak) +**Источники:** архитектура `HAN_chat_specification`; макет Figma (**не канон** — только визуализация; при расхождении приоритет у этого ТЗ и arch-документов) +**Связанный backlog:** кнопка «Войти»; история устройств входа; дифференцированные ошибки OTP; debounce SMS; тестовый пользователь с фиксированным SMS-входом +**Смежно:** уведомления (контуры G/P), чат, согласия, UX-сессия, CRM Contact через `bitrix-sync` + +**Нормативная часть — §1–§16.** §17 — ненормативный журнал решений и открытых вопросов; при расхождении с §1–§16 приоритет у §1–§16. При расхождении этого документа с arch/module после их обновления — приоритет у arch/module до синхронизации. + +--- + +## 1. Цель + +Дать клиенту устойчивую идентичность в HAN Chat: вход по подтверждённому телефону, локальную карточку пользователя в App DB, согласия, readonly-профиль в ЛК и связь с Contact в Bitrix24 — без смешения гостевого просмотра и персонального кабинета. + +Гость изучает сервис без записи в App DB. Авторизованный клиент получает персональные данные, чат, персональные уведомления и профиль. Auth-идентичность принадлежит Keycloak; приложение владеет бизнес-карточкой пользователя, согласиями и кэшем профиля для UI. + +--- + +## 2. Два режима клиента + +| Режим | Кто это | Идентичность на бэкенде | Что доступно | +|---|---|---|---| +| **G. Гость** | Клиент без действующего JWT | Нет `UserIdentity`; опциональный локальный `guest_session_id` только на устройстве | UI + `GET /api/v1/public/*`; гостевые уведомления (контур G) | +| **A. Авторизованный** | Клиент с валидным access token и выполненным `bootstrap` | `user_identities` (`keycloak_sub` ↔ JWT `sub`) | JWT API: чат, профиль, согласия, персональные уведомления, UX-сессия | + +Правила: + +1. До успешного OTP + `bootstrap` клиент — только гость. Write-endpoint (`consents`, `session-start`, чат, профиль и т.д.) **требуют JWT**. +2. После авторизации гостевой контент **не** переносится в персональный (как в уведомлениях: G не мигрирует в P). +3. `guest_session_id` **не** является auth и **не** открывает write API. +4. Наличие refresh token в secure storage позволяет вернуться в режим **A** без OTP (§6.3); отсутствие/истечение refresh → снова гость до следующего защищённого действия. +5. Access token проверяет `api-backend`; refresh выполняет **только frontend** через Keycloak. Backend refresh **не** делает. + +--- + +## 3. Границы релиза + +### 3.1. В scope + +- OTP-only вход по номеру телефона (Keycloak; mock до controlled SMS cutover). +- OIDC Authorization Code + PKCE; refresh / logout; silent return без OTP при валидном refresh. +- Локальный `find-or-create` `UserIdentity` + минимальный `ClientProfile` через `POST /api/v1/auth/bootstrap`. +- Согласия: обязательные `personal_data` + `user_agreement`, опциональный `marketing`; фиксация версий в `user_consents`. +- Readonly блочный профиль `GET /api/v1/me`; зарезервированный `GET /api/v1/me/documents` (MVP может быть пустым). +- Аналитическая `UxSession` для авторизованного клиента (`session-start`, заголовок `X-Ux-Session-Id`). +- Асинхронный map/create Contact в Bitrix24 по телефону через `sync_queue` (не блокирует вход). +- Нормализация телефона в E.164; phone claim только из JWT, не из body клиента. +- Ownership: все пользовательские ресурсы адресуются через `user_id` из JWT. + +### 3.2. Вне scope + +- Пароль, email-OTP, social login, magic link. +- Редактирование профиля клиентом (PATCH/PUT отсутствуют). +- Доставка документов компании в блок «Документы» (post-MVP; API зарезервирован). +- История устройств входа (backlog п.19) — модель и UI позже. +- Смена телефона как продуктовый self-service flow (policy Keycloak есть; продуктовый UX — отдельно). +- Merge/reassignment двух `sub` на один телефон — только administrative policy, не side effect login. +- Гостевая запись согласий и UX-сессии в App DB. +- Создание `UserIdentity` / `ClientProfile` сервисом `bitrix-sync` (sync не участвует в OTP-flow). + +--- + +## 4. Изменения UI (относительно гостевого экрана) + +| Место | Гость | Авторизованный | +|---|---|---| +| Главная | Публичный контент, гостевые уведомления | Персональные уведомления, чат доступен | +| Отправка сообщения / популярный вопрос | Сначала согласия → OTP → bootstrap → отправка отложенного текста | Штатная отправка | +| Центр уведомлений | Auth-gate «Авторизоваться» | Список персональных | +| Профиль | Недоступен / ведёт на вход | Readonly блоки «Личные данные», «Документы» | +| Кнопка «Войти» | Запускает поток авторизации | Скрыта / заменена профилем (по макету) | +| Выход | — | Очистка tokens → гостевой UI | + +Визуал — по Figma. Figma не канон поведения и состава полей профиля: канон — §5.5 и arch-01. + +--- + +## 5. Бизнес-модель + +### 5.1. Слои идентичности (не смешивать) + +| Слой | Где живёт | Что хранит | Master | +|---|---|---|---| +| **IdP user** | Keycloak (`keycloak` schema) | Realm user, phone verified, sessions, tokens | Keycloak | +| **UserIdentity** | App DB `user_identities` | Локальный `user_id`, связь `keycloak_sub`, кэш auth-телефона, `last_login_at` | Keycloak для телефона/`sub`; App DB для бизнес-FK | +| **ClientProfile** | App DB `client_profiles` | Кэш полей UI + `bitrix_contact_id` | UI-поля — последнее успешно синхронизированное значение (входящий поток MVP — Bitrix24); auth-телефон инициирует sync, но master телефона — Keycloak | +| **Bitrix Contact** | CRM Bitrix24 | Карточка клиента в CRM | Bitrix24 для CRM-полей; связь через `bitrix_contact_id` / `entity_external_mapping` | +| **Гость** | Только устройство | UI-state, локальные согласия до OTP, опционально `guest_session_id` | Нет серверной записи | + +**Инвариант:** один verified phone ↔ один active Keycloak `sub`. Один `sub` ↔ одна active `UserIdentity`. Один `user_id` ↔ один `ClientProfile`. + +### 5.2. Связанные сущности (часть домена «пользователь», но не сам User) + +| Сущность | Роль | Когда появляется | +|---|---|---| +| `UserConsent` | Факт принятия документа конкретной версии | `bootstrap` или `POST /consents` | +| `UxSession` | Аналитический период активности | `session-start` **только** у авторизованного | +| `Dialog` / `Message` | Чат с оператором | После auth, лениво при первом сообщении | +| Notification (P) | Персональные уведомления | `user_id` NOT NULL | + +`UxSession` **не** является механизмом авторизации и **не** заменяет JWT. + +### 5.3. Согласия + +| Тип | Обязательность (seed) | Документ / URL | Версия | +|---|---|---|---| +| `personal_data` | да (`consent.personal_data.required=true`) | `consent.personal_data.document_url` (+ политика `consent.privacy_policy.document_url`) | `consent.personal_data.version` | +| `user_agreement` | да | `consent.user_agreement.document_url` | `consent.user_agreement.version` | +| `marketing` | нет | `consent.marketing.document_url` | `consent.marketing.version` | + +Правила: + +1. Pop-up согласий показывается **до** OTP; до получения JWT факт принятия хранится **только на клиенте**. +2. Серверная фиксация — в `bootstrap` (атомарно с созданием пользователя) или позже через `POST /api/v1/consents` при смене версий документов. +3. Запись `UserConsent` **immutable**: unique `(user_id, consent_type, document_version)`; исправление — новая версия документа или administrative action с audit. +4. Обязательные согласия без `accepted: true` → `403 consents_required`; вход в ЛК / write API с непринятыми актуальными обязательными версиями блокируется. +5. Keycloak consent screen **не** заменяет продуктовые согласия API. + +### 5.4. Auth-телефон + +- Единственный канал MVP: номер телефона + OTP. +- Нормализация: libphonenumber → canonical E.164. +- В App DB телефон пишется **только** из JWT claims при `bootstrap` / обновлении identity, **никогда** из body клиента. +- Порядок claim: `phone_number`, иначе `preferred_username` только если значение валидно как E.164. +- Отсутствие/невалидность при bootstrap → `400 phone_claim_missing`. +- Утечка существования номера запрещена на стороне Keycloak (одинаковый внешний ответ для нового/существующего). +- В `ClientProfile` при создании копируется в `russian_phone` (минимальный профиль); дальнейшее обогащение — из CRM sync. + +### 5.5. Профиль (UI) + +Блочная модель. Редактирование клиентом **недоступно**. + +**Блок «Личные данные»:** + +| Поле | Источник отображения | Примечание | +|---|---|---| +| ФИО (`full_name`) | `client_profiles` | Может быть `null` до sync из Bitrix24 | +| Гражданство (`citizenship`) | `client_profiles` | `null` до заполнения | +| Телефон РФ (`russian_phone`) | `client_profiles` | При bootstrap = auth-телефон | +| Зарубежный телефон (`foreign_phone`) | `client_profiles` | Опционально | +| Email (`email`) | `client_profiles` | Опционально | + +**Блок «Документы»:** + +- перечень документов компании, дата, наименование, скачивание; +- в MVP список может быть пустым; доставка из Bitrix24 — post-MVP; +- API: `GET /api/v1/me/documents`, `GET /api/v1/documents/{id}`, `.../download-url` с audit. + +Макет Figma может показывать дополнительные секции (патент, РВП и т.п.) — это **не** канон MVP-модели данных; расширение блоков — отдельное решение. + +### 5.6. Жизненный цикл пользователя + +| Состояние | Условие | Что видит клиент | +|---|---|---| +| Гость | Нет валидного JWT | Публичный UI | +| OTP in progress | Идёт challenge в Keycloak | Экраны телефона / кода | +| Authenticated, bootstrap pending | Есть JWT, нет local `UserIdentity` | Frontend обязан вызвать `bootstrap`; прочие protected → `409` «bootstrap required» | +| Authenticated, ready | Есть `UserIdentity` + актуальные обязательные согласия | Полный ЛК | +| Soft-deleted | `record_status='D'` на identity (админ) | Доступ запрещён; детали — operational policy | + +Бизнес-«удаление аккаунта» клиентом в MVP **не** моделируется. Soft-delete — административный контур (arch-05). + +### 5.7. Константы (`app_settings`) + +| Ключ | Смысл | Default / seed | +|---|---|---| +| `auth.phone.enabled` | Вход по телефону | `true` | +| `auth.password.enabled` | Пароль | `false` | +| `otp.phone.max_send_attempts_per_24h` | Лимит отправок OTP | `3` | +| `otp.phone.min_seconds_between_attempts` | Минимальный интервал между отправками | `30` | +| `otp.phone.max_verify_attempts` | Лимит проверок кода | `5` | +| `otp.phone.code_length` | Длина кода | `6` | +| `otp.phone.ttl_seconds` | TTL кода | `60` | +| `otp.phone.sms_order_timeout_ms` | Таймаут заказа SMS | `3000` | +| `consent.*` | URL/версии/required флагов согласий | см. arch-04 | +| `ux.session.idle_timeout_minutes` | Idle → новая UX-сессия | `30` | + +Счётчики OTP ведёт **Keycloak/SPI**, не `api-backend`. Продуктовые `otp.phone.*` Keycloak читает через settings bridge `GET /internal/settings/v1/otp`. + +--- + +## 6. Поведение UI и сценарии + +### 6.1. Гостевой режим + +1. Клиент открывает приложение → гостевой UI. +2. Доступен только `GET /api/v1/public/*` (+ статика). +3. Согласия и `session-start` в App DB **не** пишутся. +4. Попытка защищённого действия (сообщение, Центр уведомлений, профиль) → поток авторизации. + +### 6.2. Поток первой авторизации (OTP) + +1. Триггер: отправка сообщения / популярный вопрос / «Войти» / иное действие, требующее auth. +2. Pop-up согласий; обязательные должны быть приняты локально. +3. Форма телефона → Keycloak OTP-flow (mock или real SMS через `sms-service`). +4. Успешная проверка OTP → tokens (Authorization Code + PKCE). +5. `POST /api/v1/auth/bootstrap` с локальными согласиями и `device` metadata. +6. `POST /api/v1/analytics/session-start` при необходимости новой UX-сессии. +7. Триггер БД ставит `contact.map_or_create` в `sync_queue` (асинхронно; ошибка CRM **не** откатывает вход). +8. Frontend продолжает исходное действие (в т.ч. отложенное сообщение / популярный вопрос). + +**UX-инвариант (backlog п.21):** ошибка отправки отложенного сообщения после успешного bootstrap **не** должна выглядеть как «не удалось завершить вход». Вход завершён на шаге 5–6; ошибка Bitrix/чата показывается в контексте чата. + +### 6.3. Возврат без OTP + +1. Есть валидный refresh token → Refresh Token Grant → access token. +2. При необходимости — `session-start`. +3. OTP не показывается. +4. Нет/истёк refresh → гость до следующего защищённого действия. + +### 6.4. Поддержание сессии (tokens) + +- Frontend проактивно обновляет access token (~60 с до `exp`), single-flight. +- Успешный refresh **не** создаёт новую UX-сессию. +- `401` от API → один refresh + retry исходного запроса; провал refresh → очистка tokens → гость. +- То же для WebSocket `/api/v1/realtime`. + +### 6.5. UX-сессия + +Новая `UxSession` только при: + +| `start_reason` | Когда | +|---|---| +| `first_launch` | В памяти нет `ux_session_id` | +| `cold_start` | Kill app / закрытие вкладки | +| `idle_timeout` | Простой > `ux.session.idle_timeout_minutes` | + +`ux_session_id` хранится **только в памяти** (не в localStorage). Передаётся как `X-Ux-Session-Id`. Отсутствие заголовка API не блокирует (кроме endpoint, где id обязателен). + +### 6.6. Профиль + +- Открывается только авторизованным. +- Данные — `GET /api/v1/me`; поля могут быть частично пустыми до CRM sync. +- Редактирование недоступно; изменение ФИО/email и т.п. — через процессы компании (Bitrix24 → sync). +- Документы — отдельный блок; скачивание с audit. + +### 6.7. Выход + +1. Frontend инициирует logout у Keycloak (revocation по policy модуля). +2. Очищает access/refresh tokens и in-memory UX-сессию. +3. UI переходит в гостевой режим. +4. Локальный `UserIdentity` в App DB **не** удаляется. + +--- + +## 7. Матрицы поведения + +### 7.1. Что требует auth + +| Действие | Гость | Авторизованный | +|---|---|---| +| `GET /api/v1/public/*` | да | да | +| Просмотр главной / гостевых уведомлений | да | нет (после входа — только P) | +| Отправка сообщения / вложение | нет → OTP | да | +| `POST /auth/bootstrap` | нет (нужен JWT после OTP) | да (идемпотентно) | +| `POST /consents`, `session-start` | нет | да | +| `GET /me`, чат, персональные уведомления | нет | да | +| `WS /api/v1/realtime` | нет | да | + +### 7.2. Источник истины полей + +| Поле / факт | Master | Куда кэшируется | +|---|---|---| +| `sub` / существование IdP user | Keycloak | `user_identities.keycloak_sub` | +| Auth-телефон | Keycloak | `user_identities.phone_number`, seed `client_profiles.russian_phone` | +| Согласия (версия + accepted) | App DB `user_consents` | — | +| ФИО, гражданство, email, foreign_phone | Последний успешный sync (MVP: Bitrix → App) | `client_profiles` | +| `bitrix_contact_id` | Результат `bitrix-sync` | `client_profiles` | +| Tokens / auth session | Keycloak | secure storage на клиенте | +| `ux_session_id` | App DB + память клиента | заголовок запросов | + +### 7.3. Bootstrap — идемпотентность + +| Повторный вызов | Результат | +|---|---| +| Тот же `sub`, те же версии согласий | `200`, тот же `user_id`; `last_login_at` обновляется; дублей consent нет | +| Тот же `sub`, новые версии согласий | Новые immutable строки consent + update identity | +| JWT без phone claim | `400 phone_claim_missing` | +| Обязательные consents не accepted | `403 consents_required` | + +Application-код **не** пишет в `sync_queue`: задачи создают триггеры на insert/update `UserIdentity` / `ClientProfile`. + +--- + +## 8. Идентификация + +| Идентификатор | Назначение | +|---|---| +| `keycloak_sub` | Subject JWT; ключ find-or-create | +| `user_id` | PK `user_identities`; FK всех персональных сущностей App DB | +| `phone_number` | Auth-телефон E.164 | +| `guest_session_id` | Локальный UUID устройства; не auth | +| `ux_session_id` | Аналитическая сессия | +| `bitrix_contact_id` | Contact в CRM (после sync) | +| `device_id` | Opaque id устройства в bootstrap / session-start; в audit/log не копируется как PII | + +Публичные id — **UUID** (в App DB предпочтительно UUID v7, как в остальных доменах). + +--- + +## 9. API + +Общие конвенции — arch-02. + +### 9.1. Клиентские (JWT) + +| Метод и путь | Назначение | +|---|---| +| `POST /api/v1/auth/bootstrap` | Find-or-create пользователя + согласия | +| `POST /api/v1/consents` | Повторная фиксация версий согласий | +| `POST /api/v1/analytics/session-start` | Новая `UxSession` | +| `GET /api/v1/me` | Readonly блочный профиль | +| `GET /api/v1/me/documents` | Список документов (MVP может быть пустым) | +| `GET /api/v1/documents/{id}` | Metadata документа (owner only) | +| `GET /api/v1/documents/{id}/download-url` | Presigned GET + audit | + +Auth у Keycloak: публичные OIDC endpoints через `/auth/*` (не часть `api-backend`). + +### 9.2. Public (без JWT) + +| Метод и путь | Назначение | +|---|---| +| `GET /api/v1/public/settings` (и связанные public) | Флаги auth, URL/версии согласий, OTP UI-параметры по `is_public` | + +### 9.3. Internal (смежные) + +| Метод и путь | Кто → кто | Назначение | +|---|---|---| +| `GET /internal/settings/v1/otp` | Keycloak SPI → api-backend | Продуктовые OTP limits | +| `POST /internal/sms/v1/send` | Keycloak → sms-service | Заказ SMS OTP (real mode) | + +### 9.4. Ошибки (домен пользователя) + +| Код | HTTP | Когда | +|---|---|---| +| `phone_claim_missing` | 400 | Нет канонического телефона в JWT при bootstrap | +| `validation_error` | 400 | Невалидные версии/тело согласий или device | +| `unauthorized` | 401 | Нет/невалиден JWT | +| `consents_required` | 403 | Обязательные согласия не приняты | +| `resource_state_conflict` | 409 | Protected endpoint до bootstrap («bootstrap required»); **открытый вопрос TBD-1** по унификации с `404` для consents | +| `profile_not_found` | 404 | Профиль не найден (по контракту envelope) | +| `rate_limit_exceeded` | 429 | Превышен лимит | + +Дифференцированные тексты ошибок OTP на UI (неверный код / истёк / лимит send / лимит verify) — backlog п.10; контракт Keycloak/frontend уточняется отдельно, в этом ТЗ фиксируется требование продукта. + +### 9.5. Rate limiting + +| Зона | Identity | +|---|---| +| Auth edge (`nginx`) | IP | +| bootstrap / consents / session-start | user + IP | +| OTP product limits | phone (Keycloak counters) | + +--- + +## 10. Модель данных (схема `han_app`) + +Общие правила — module-01 §9.1 / arch-05: UUID PK, `timestamptz` UTC, common fields, soft-delete `A`/`D`, FK `ON DELETE RESTRICT`. + +### 10.1. `user_identities` + +| Поле | Тип | Описание | +|---|---|---| +| `id` | uuid PK | `user_id` | +| `keycloak_sub` | varchar(255) NOT NULL UNIQUE | JWT `sub` | +| `phone_number` | varchar(32) NOT NULL | E.164 из JWT | +| `last_login_at` | timestamptz NOT NULL | Обновляется на bootstrap | +| common fields | обязательны | | + +Индексы: unique `keycloak_sub`; index на `phone_number` для CRM map. **Телефон в App DB не unique:** identity master — Keycloak; временный конфликт при merge/миграции допустим на уровне данных, но продуктово один phone = один active `sub`. + +### 10.2. `user_consents` + +| Поле | Тип | Описание | +|---|---|---| +| `id` | uuid PK | | +| `user_id` | uuid FK | | +| `ux_session_id` | uuid NULL | Если сессия уже есть | +| `consent_type` | varchar | `personal_data` \| `user_agreement` \| `marketing` | +| `document_version` | varchar | Версия из `app_settings` | +| `accepted` | boolean | | +| `accepted_at` | timestamptz | | +| `client_ip` | inet | | +| `user_agent_hash` | varchar | | +| device snapshot | jsonb / поля | По module-01 (`device_json` и т.п.) | +| common fields | обязательны | | + +Unique `(user_id, consent_type, document_version)`. Записи immutable. + +### 10.3. `client_profiles` + +| Поле | Тип | Описание | +|---|---|---| +| `id` | uuid PK | | +| `user_id` | uuid UNIQUE FK | 1:1 с identity | +| `bitrix_contact_id` | varchar/nullable | После успешного map | +| `full_name` | varchar NULL | | +| `citizenship` | varchar NULL | | +| `russian_phone` | varchar NULL | Seed из auth-телефона | +| `foreign_phone` | varchar NULL | | +| `email` | varchar NULL | | +| `source_updated_at` | timestamptz NULL | Метка источника sync | +| common fields | обязательны | | + +Partial unique на `bitrix_contact_id` среди active. PII не попадает в логи и generic audit payload. + +### 10.4. `ux_sessions` + +`id` = `ux_session_id`; `user_id`; `start_reason`; `platform`; `app_version`; `device_id`; `started_at`; common fields. + +### 10.5. Триггеры sync + +| Событие | `task_type` | +|---|---| +| Insert active `UserIdentity` / `ClientProfile` без mapping | `contact.map_or_create` | +| Изменение tracked profile / auth-phone полей | `contact.update` | + +Подавление эха: GUC `han.sync_suppress` при записи из `bitrix-sync`. Ошибка CRM не откатывает bootstrap и чат. + +--- + +## 11. Фоновые и смежные процессы + +| Процесс | Владелец | Связь с пользователем | +|---|---|---| +| OTP challenge / counters / expiry | Keycloak SPI | До появления App user | +| SMS order / delivery journal | `sms-service` | Только доставка кода | +| `contact.map_or_create` / `contact.update` | `bitrix-sync` | После bootstrap / изменения профиля | +| Token refresh / logout | Frontend + Keycloak | Не трогает App DB identity | +| Retention UX-сессий (если введён) | ops / module | Не удаляет `UserIdentity` | + +--- + +## 12. Audit и observability + +| `event_type` | Actor | Когда | +|---|---|---| +| `auth.bootstrap` | user | Успешный bootstrap | +| `consent.recorded` | user | Запись согласий (bootstrap или `/consents`) | +| `session_start` | user | Новая UX-сессия | +| `document.download_url_issued` | user | Скачивание документа профиля | +| OTP security events | Keycloak | Send/verify attempts (schema `keycloak`, phone HMAC/masked) | + +В audit **нет:** полного phone/email/name в свободном тексте логов общего контура, OTP raw code, tokens, presigned URL. + +Метрики (минимум): число bootstrap/сутки, доля `consents_required`, доля `phone_claim_missing`, latency map Contact, доля пользователей без `bitrix_contact_id` спустя N минут после входа. + +--- + +## 13. Хранение данных и PII + +- `UserIdentity`, `ClientProfile`, `UserConsent` — прикладные строки, soft-delete, физическое удаление запрещено (arch-05). +- Auth-мастер PII телефона — Keycloak; App DB держит кэш для FK/CRM/UI. +- Согласия хранятся бессрочно как юридически значимый журнал (immutable rows). +- Гостевые локальные согласия на устройстве до OTP **не** являются серверным журналом и при сбое до bootstrap могут быть потеряны — клиент проходит согласия снова. +- Right-to-erasure / удаление аккаунта клиентом — вне scope MVP; потребует отдельной политики по IdP + App DB + CRM. + +--- + +## 14. Смежные сервисы + +| Компонент | Ответственность в домене User | +|---|---| +| **Frontend** | Гость/ЛК, согласия UI, OTP UX, tokens, refresh, bootstrap/session-start, профиль readonly, отложенное сообщение после входа | +| **Keycloak** | IdP, OTP, phone uniqueness, tokens, sessions | +| **api-backend** | Bootstrap, consents, me/profile, JWT validation, ownership, settings bridge OTP | +| **sms-service** | Durable order SMS (real mode) | +| **bitrix-sync** | Map/update Contact; **не** создаёт UserIdentity | +| **nginx** | `/auth/*`, edge rate limit auth | +| **App DB** | Таблицы §10, триггеры sync | + +Порядок работ (если дорабатывать домен): (1) Keycloak OTP + phone claims → (2) bootstrap + consents + identity/profile → (3) session-start → (4) me/profile UI → (5) CRM map → (6) documents post-MVP. + +--- + +## 15. Влияние на arch-документы + +| Документ | Статус относительно этой постановки | +|---|---| +| `arch-00-glossary.md` | Термины `UserIdentity`, `ClientProfile`, `UserConsent`, `UxSession`, `guest_session_id`, `keycloak_sub` уже заданы | +| `arch-01-system-architecture.md` | Потоки гостя, OTP, возврата, профиля — канон сценариев | +| `arch-02-api-contracts.md` | Контракты bootstrap / consents / session-start / me | +| `arch-04-settings-and-content.md` | `auth.*`, `otp.phone.*`, `consent.*`, `ux.session.*` | +| `module-01-api-backend.md` | Таблицы и алгоритмы bootstrap | +| `module-08-keycloak.md` | OTP-only phone flow | +| `module-07-bitrix-sync.md` | Обработка `contact.*` задач | + +Этот документ **не заменяет** module-спеки; он собирает бизнес-смысл сущности «Пользователь» для аналитики и смежных фич (уведомления, чат, документы). + +--- + +## 16. Критерии приёмки + +1. Гость видит только public API; write без JWT недоступен; `guest_session_id` не открывает API. +2. Вход только по телефону + OTP; пароль/email/social отсутствуют. +3. После OTP `bootstrap` создаёт/находит `UserIdentity`, минимальный `ClientProfile`, пишет согласия; телефон берётся из JWT, не из body. +4. Повторный bootstrap идемпотентен; `last_login_at` обновляется. +5. Без обязательных согласий — `403 consents_required`; без phone claim — `400 phone_claim_missing`. +6. Protected endpoint до bootstrap — безопасный отказ (`409` до закрытия TBD-1). +7. Возврат с валидным refresh — без OTP; провал refresh — гостевой UI. +8. `session-start` только с JWT; в гостевом режиме не вызывается; idle/cold/first_launch создают новую UX-сессию. +9. `GET /me` отдаёт блочный readonly профиль; PATCH/PUT нет. +10. Ошибка CRM sync не ломает вход; Contact мапится асинхронно. +11. Один active phone ↔ один active `sub` на стороне Keycloak; merge не происходит молча при login. +12. Отложенное сообщение / популярный вопрос после auth уходит штатно; ошибка доставки не маскируется под ошибку входа. +13. Выход очищает tokens и возвращает в гостевой UI, не удаляя `UserIdentity`. +14. PII не светится в обычных логах/audit payload; OTP code не логируется. + +--- + +## 17. Журнал решений и открытых вопросов (ненормативно) + +### 17.1. Принятые решения + +| # | Решение | Раздел / источник | +|---|---|---| +| D1 | Два режима: гость и авторизованный; гостевые данные в App DB не пишутся | §2, arch-01 | +| D2 | Слои IdP / UserIdentity / ClientProfile / Bitrix Contact разделены; master auth — Keycloak | §5.1 | +| D3 | OTP-only phone; password disabled | §3, module-08 | +| D4 | Согласия продуктовые в API; Keycloak их не заменяет; серверная запись только после JWT | §5.3 | +| D5 | Телефон только из JWT claims | §5.4, arch-02 | +| D6 | Профиль readonly и блочный; документы — отдельный блок, доставка post-MVP | §5.5 | +| D7 | Bootstrap атомарный + идемпотентный; sync через триггеры БД | §7.3 | +| D8 | UX-сессия ≠ auth; только для авторизованных; хранение id в памяти | §6.5, arch-00 | +| D9 | CRM не блокирует авторизацию | §6.2 | +| D10 | Один verified phone = один active `sub` | module-08 | + +### 17.2. Открытые вопросы + +| # | Вопрос | Предложение | Влияние | +|---|---|---|---| +| Q1 | Единый код для protected endpoint до bootstrap: `409` vs `404` (TBD-1 module-01) | Оставить `409 resource_state_conflict` | OpenAPI, клиентский UX | +| Q2 | Дифференцированные ошибки OTP на UI (backlog п.10) | Зафиксировать словарь кодов Keycloak → frontend texts | module-08 + frontend | +| Q3 | История устройств входа (backlog п.19) | Отдельная сущность/таблица, не смешивать с `UxSession` | Новая постановка | +| Q4 | Debounce/backoff SMS после интеграции провайдера (backlog п.11) | Надстройка над `otp.phone.min_seconds_between_attempts` | Keycloak SPI | +| Q5 | Тестовый пользователь с фиксированным SMS (backlog п.16) | Операционный allow-list / mock per-phone, не дырка в prod limits | ops + module-08 | +| Q6 | Продуктовый self-service смены телефона | Позже: re-auth + OTP нового номера + invalidate sessions | Keycloak + bootstrap | +| Q7 | Клиентское удаление аккаунта / right-to-erasure | Вне MVP; отдельная юридическая и техническая постановка | IdP + App + CRM | +| Q8 | Расширение блоков профиля сверх «Личные данные» / «Документы» (как в Figma-моках) | Только после продуктового решения; Figma не канон | UI + `client_profiles` / новые таблицы | + +--- + +## 18. Связь с уведомлениями + +| Аспект | Гость | Авторизованный пользователь | +|---|---|---| +| Контур уведомлений | G (`guest_notifications`) | P (`notifications.user_id`) | +| Бейдж непрочитанных | нет | да | +| Центр уведомлений | auth-gate | список | +| Перенос G → P при логине | **запрещён** | — | + +Домен User задаёт, **кто** видит контур P; домен Notification задаёт **что** показывается. Владелец персональных записей — всегда `user_identities.id`. diff --git a/modules/module-01-api-backend.md b/modules/module-01-api-backend.md index 018f1f7..692c53d 100644 --- a/modules/module-01-api-backend.md +++ b/modules/module-01-api-backend.md @@ -32,6 +32,7 @@ - аудит, метрики, трассировку, health и readiness; - чтение `app_settings`, `text_resources`, `popular_questions`; - internal settings bridge для Keycloak SPI. +- Notification Center G/P: каталог, public/JWT/internal API, lifecycle, документы, realtime и фоновые задачи. ### 2.2. Сервис не отвечает за @@ -150,6 +151,8 @@ api-backend/ safety_recovery.py quarantine_cleanup.py inbound_attachment.py + notification_expire.py + notification_draft_cleanup.py settings.py alembic/ tests/ @@ -455,6 +458,14 @@ Complete request: `{"checksum":"sha256:<64-lowercase-hex>"}`. Response `200` в Init и complete должны быть idempotent по состоянию attachment; повтор complete с тем же checksum возвращает прежний результат, с другим — `409 resource_state_conflict`. +### 6.8. Notification Center + +Контракты путей и DTO — arch-02 и `notification-requirements.md`. Реализация читает `notification_types` и реестры CTA/кнопок/цветов; ветвление по `notification_type` запрещено. Home/center/counter применяют серверные лимиты 7/15, эффективный приоритет `COALESCE(priority_override, type.priority)` и ownership. + +Действие скрытия всегда ставит `visibility='hidden'`. Если `date_expired` уже задано, оно сохраняется; TTL вида/default устанавливает `date_expired=now()+N days` только при `NULL`. Первое скачивание любого связанного документа при `hide_on_document_download=true` атомарно применяет этот эффект один раз. + +`install_app_prompt` возвращает только внешнее действие открытия `instruction_url` в новой вкладке. Режимы iframe/modal и allow-list для них отсутствуют. + ## 7. Internal endpoints ### 7.1. Inbox Open Lines @@ -524,6 +535,13 @@ Private network + Bearer `KEYCLOAK_SETTINGS_BRIDGE_TOKEN`. Все вызовы: private network, `Authorization: Bearer `, `X-Request-ID`, `traceparent`, idempotency по `message_id`. +### 7.4. Internal Notifications + +- `POST /internal/notifications/v1/notifications` — Create; +- `POST /internal/notifications/v1/notifications/cancel` — Cancel. + +Bearer token отдельный для каждого `source`; secret приходит из `NOTIFICATIONS_TOKEN_`, в `notification_sources` хранится только hash, сравнение constant-time. `producer_test`/`NOTIFICATIONS_TOKEN_PRODUCER_TEST` служат smoke API. `(source, external_id)` уникальна бессрочно: одинаковый fingerprint → `200`, другой → `409 notification_conflict`. + ## 8. JWT, JWKS и phone claims ### 8.1. Validation @@ -972,8 +990,8 @@ Outbox worker и synchronous first attempt используют один dispatc ```json {"type":"connected","server_time":"2026-07-09T12:00:00Z"} -{"type":"subscribe","dialog_ids":["uuid"]} -{"type":"subscribed","dialog_ids":["uuid"]} +{"type":"subscribe","dialog_ids":["uuid"],"notifications":true} +{"type":"subscribed","dialog_ids":["uuid"],"notifications":true} ``` Events: @@ -981,9 +999,10 @@ Events: - `message.new` с `MessageResponse`; - `message.status`; - `dialog.status`; +- `notification.created`, `notification.updated`, `notification.closed` через `han:rt:user:{user_id}`, включая эхо инициатору; - `ping`; client отвечает `pong`. -Каждый subscribe проверяет ownership всех dialogs; чужие id не раскрываются. Лимиты: max connections/user, max subscriptions/connection, max frame bytes, subscribe rate. Slow consumer: bounded queue; при переполнении connection закрывается с retryable code, клиент восстанавливается polling. +`notifications` опционально и по умолчанию `false`. Каждый subscribe проверяет ownership всех dialogs; чужие id не раскрываются. Лимиты: max connections/user, max subscriptions/connection, max frame bytes, subscribe rate. Slow consumer: bounded queue; при переполнении connection закрывается с retryable code, клиент восстанавливается polling. ### 17.2. Delivery semantics @@ -1025,6 +1044,7 @@ Runtime refresh: poll `MAX(updated_at)` каждые 30 секунд; новый - `KEYCLOAK_SETTINGS_BRIDGE_TOKEN`; - `SELECTEL_S3_ENDPOINT_URL`, три bucket names, write access key/secret; - `OTEL_EXPORTER_OTLP_ENDPOINT`. +- `NOTIFICATIONS_TOKEN_PRODUCER_TEST` и последующие `NOTIFICATIONS_TOKEN_`; plaintext не сохраняется в БД/логах. `SELECTEL_S3_QUARANTINE_READ_*` принадлежит `message-safety`, не должен передаваться контейнеру API. Новые env сначала документируются в arch-04. @@ -1039,6 +1059,8 @@ Runtime refresh: poll `MAX(updated_at)` каждые 30 секунд; новый | create dialog/message | user + dialog + IP | | attachment init/complete | user + dialog | | download URL | user + resource group | +| notifications read/action/upload | user + IP / user / user | +| notifications public | IP hash | | WS connect/subscribe | user + IP | | internal inbox/settings | service identity + source network | @@ -1119,6 +1141,8 @@ Graceful shutdown прекращает принимать новые requests, - `attachment.upload_initialized/completed/promoted/rejected`; - `attachment.download_url_issued`; - `document.download_url_issued`; +- `notification.created/read/hidden/cta_invoked/button_pressed/closed`; +- `notification.document.download_url_issued`, `notification.documents.submitted`, `notification.expired_batch`; - `openlines.inbox_applied`; - повторные severe rate limit violations. @@ -1199,6 +1223,8 @@ Startup: 8. start workers; 9. mark ready. +Notification expire запускается ежедневно в `notification.expire_job.run_at` с PostgreSQL advisory lock и set-based update; массовые WS-события не публикует. Cleanup удаляет просроченные drafts и соответствующие S3-объекты идемпотентно. Отдельные Compose-процессы используют зарегистрированные scripts `han-notification-expire-worker` и `han-notification-draft-cleanup-worker`; общий `han-cleanup-worker` сохраняет прежнюю очистку quarantine. + Nginx маршрутизирует `/api/*`, включая WS `/api/v1/realtime`. Для message POST `proxy_read_timeout >= MESSAGE_SAFETY_TASK_POLL_MAX_SEC + 30s`. Internal paths наружу не маршрутизируются. ## 25. Ключевые user flows diff --git a/modules/module-03-nginx.md b/modules/module-03-nginx.md index d43d587..b692302 100644 --- a/modules/module-03-nginx.md +++ b/modules/module-03-nginx.md @@ -23,7 +23,7 @@ | exact `/health/live`, `/health/ready` | `bitrix-local-app:8080` | по умолчанию не публикуются; только при явно выбранной ops/monitoring policy | | `/` | static SPA либо Expo dev upstream | `try_files` fallback | -`/internal/`, `/_internal/`, Redis/OTLP/admin/status/config files запрещены exact prefix response `404` (допустим `403`, но единообразно выбран `404`). Никакого fallback internal path в SPA или общий proxy. `message-safety` и `/internal/sms/*` не имеют публичного route. +Notification paths внутри `/api/` имеют отдельные edge-зоны: public catalog/campaigns, JWT read, actions, uploads и downloads. `/internal/`, `/_internal/`, Redis/OTLP/admin/status/config files запрещены exact prefix response `404` (допустим `403`, но единообразно выбран `404`). Никакого fallback internal path в SPA или общий proxy. `message-safety`, `/internal/sms/*` и `/internal/notifications/*` не имеют публичного route. ## 3. Upstreams @@ -118,6 +118,10 @@ traceparent: входной валидный либо новый согласн - `api`: общий API; - `polling`: GET messages fallback; - `downloads`: issuance URL; +- `notifications_read`: list/counter/detail; +- `notifications_action`: read/hide/CTA/button; +- `notification_upload`: универсальные upload drafts; +- `notifications_public`: guest notifications и каталог видов; - `bitrix_callbacks`: мягкий burst для повторов; - `idgtl_callbacks`: отдельный bounded burst, учитывающий повтор каждые 5 минут в течение суток; - `ws_connect`: handshake; @@ -158,6 +162,7 @@ CORS — exact allow-list из согласованного deploy config; appli - connect-src `'self'` `https:` к разрешённому S3 endpoint и `wss:` текущего host; - img-src `'self' data: blob:` и разрешённые signed HTTPS resources; - object-src `'none'`, base-uri `'self'`, frame-ancestors `'none'`; +- frame-src `'none'`: инструкция `install_app` всегда открывается в новой вкладке, iframe/модалка не поддерживается; - script-src без `unsafe-eval` production; nonce/hash при необходимости; - style-src policy согласовать с Expo build, постепенно исключить unsafe-inline. @@ -260,6 +265,8 @@ curl -i https://tohin.ru/internal/safety/v1/messages/check - upstream down/timeout, failed reload, renewal rehearsal; - logs не содержат secrets/query tokens. - allowed Direct callback проходит; wrong IP/method и любой `/internal/sms/*` отклоняются; Authorization отсутствует в логах. +- `/internal/notifications/*` снаружи всегда `404`; notification read/action/upload/public routes используют свои зоны и возвращают `429`. +- CSP содержит `frame-src 'none'`; инструкция проверяется как новая вкладка без embedded content. ## 19. Definition of Done diff --git a/modules/module-07-bitrix-sync.md b/modules/module-07-bitrix-sync.md index c43713c..a38c3e7 100644 --- a/modules/module-07-bitrix-sync.md +++ b/modules/module-07-bitrix-sync.md @@ -25,6 +25,8 @@ Упоминания полноценного sync в arch-01/02/03 описывают будущую целевую границу, а не функциональность этого stub. Расширение требует новой версии спецификации, migrations/GRANT, OpenAPI и contract tests. +Notification Center добавляет в `han_app.sync_queue` task type `document.client_uploaded`, но stub его **не claim-ит и не подтверждает**: задача остаётся накопленной для будущей реализации. Producer — DB trigger на `client_documents`, dedup key — `client_document_id`; payload содержит `client_document_id`, `user_id`, `context_type`, `context_id`, `submission_id`, bucket/object key и безопасные metadata файла, без presigned URL. + ## 2. Технологический профиль - Python 3.12+, FastAPI, Pydantic v2, Uvicorn. @@ -276,6 +278,7 @@ Managed init уже создаёт: К `han_app` **не выдаются GRANT** до реализации полноценной CRM sync. Это сознательно строже общего будущего требования. Когда появится sync: - GRANT выдаётся точечно на `sync_queue`, mapping и необходимые columns; +- для `document.client_uploaded` добавляется read только `client_documents` и обработчик с идемпотентностью по `client_document_id`; - запрещён broad schema write; - GUC/write-back и trigger contract проходят integration tests; - обновляются deploy scripts и module spec. @@ -452,6 +455,7 @@ Shutdown не пишет бизнес-данные и не требует БД. - structured logs/metrics/traces без secrets; - OpenAPI, Docker healthcheck и tests готовы; - future CRM boundary документирована и не реализована скрыто. +- `document.client_uploaded` документирован как накопляемая stub-задача и не выдаётся за обработанный status. ## 18. Решения, допущения и TBD diff --git a/releases/#0 deploy-steps.md b/releases/#0 deploy-steps.md index 22197a2..9cfe6bf 100644 --- a/releases/#0 deploy-steps.md +++ b/releases/#0 deploy-steps.md @@ -48,6 +48,12 @@ rsync -rltD --no-perms --no-owner --no-group -ivc --delete \ --exclude='*.pem' \ --exclude='*.key' \ --exclude='secrets/' \ + --exclude='node_modules/' \ + --exclude='dist/' \ + --exclude='.expo/' \ + --exclude='playwright-report/' \ + --exclude='test-results/' \ + --exclude='*.log' \ -e "ssh -i ~/.ssh/hansel" \ /mnt/c/Users/MI/Documents/Assistent/HAN_chat_specification/codebase/backend/ \ root@135.106.164.58:/opt/han-chat/backend/ diff --git a/releases/#2 notifications deploy.md b/releases/#2 notifications deploy.md new file mode 100644 index 0000000..0ac9fba --- /dev/null +++ b/releases/#2 notifications deploy.md @@ -0,0 +1,317 @@ +## Накатывание Notification Center v1 + +Выполнять на сервере из каталога: + +```sh +cd /path/to/HAN_chat_specification/codebase/backend +``` + +### 1. Подготовить резервную точку + +Создайте PITR-маркер/снимок PostgreSQL. Миграция forward-only — откатывать её нельзя. + +### 2. Обновить код + +```sh +git fetch +git checkout <утверждённый-commit-or-tag> +git status --short +``` + +Рабочее дерево должно быть чистым. + +### 3. Добавить переменные в `.env` + +Не перезаписывайте существующий `.env`. Добавьте: + +```sh +NOTIFICATIONS_TOKEN_PRODUCER_TEST=<случайный-токен> + +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 +``` + +Токен можно создать так: + +```sh +openssl rand -hex 32 +chmod 600 .env +``` + +Проверить конфигурацию: + +```sh +./scripts/validate-env .env +docker compose --env-file .env config --quiet +``` + +### 4. Собрать новые образы + +```sh +docker compose --env-file .env build --pull \ + api-backend frontend-static nginx +``` + +### 5. Выполнить миграцию + +```sh +PITR_MARKER_CONFIRMED=true ENV_FILE=.env \ + deployment/scripts/migrate.sh + +deployment/scripts/seed.sh +``` + +Ожидаемая ревизия: + +```text +0008_notifications_v1 +``` + +Проверка: + +```sh +docker compose --env-file .env --profile ops run --rm \ + migrate-api alembic current +``` + +### 6. Обновить статический frontend + +```sh +docker compose --env-file .env run --rm frontend-static +``` + +### 7. Перезапустить изменённые сервисы + +```sh +docker compose --env-file .env up -d --force-recreate \ + api-backend \ + delivery-worker \ + safety-recovery-worker \ + cleanup-worker \ + notification-expire-worker \ + notification-draft-cleanup-worker \ + nginx +``` + +### 8. Проверить состояние + +```sh +docker compose ps + +docker compose logs --since=10m \ + api-backend \ + notification-expire-worker \ + notification-draft-cleanup-worker \ + nginx + +docker compose exec -T nginx nginx -t -c /tmp/nginx.conf +``` + +Проверить API: + +```sh +curl -fsS https://chat.han0107.ru/health/live +curl -fsS https://chat.han0107.ru/health/ready +curl -fsS https://chat.han0107.ru/api/v1/public/notification-types +curl -fsS https://chat.han0107.ru/api/v1/public/notifications +``` + +Внешний internal endpoint обязан возвращать `404`: + +```sh +curl -i https://chat.han0107.ru/internal/notifications/v1/notifications +``` + +### 9. Провести smoke-тест + +```sh +deployment/scripts/smoke.sh +``` + +Дополнительно проверить через `producer_test`: + +- Create возвращает `201`; +- повтор того же тела — `200`; +- то же `external_id` с изменённым телом — `409`; +- Cancel — `200`; +- повторный Cancel — `200`; +- уведомление появляется у указанного `user_id`. + +Полный эксплуатационный чек-лист находится в `codebase/backend/deployment/RUNBOOK.ru.md`. + +Важно: при проблеме не выполнять `docker compose down -v` и не откатывать Alembic. После применения `0008` безопасный путь — исправляющий релиз; старый backend может не пройти readiness из-за проверки версии схемы. + +# Создание уведомлений + +## Публичные (инсерт в БД) + +BEGIN; + +INSERT INTO han_app.guest_notifications ( + id, + notification_type, + notification_datetime, + header, + text, + priority_override, + date_expired, + price, + old_price, + instruction_url, + chat_message_text, + lifecycle_status, + closed_at, + record_status, + status_changed_at, + status_change_reason, + created_at, + updated_at, + updater_user_id +) +VALUES +-- 1. Авторизация +( + '019fa3e9-8d91-7199-8199-609916d48f04', + 'authorize', + now(), + 'Войдите в личный кабинет', + 'Авторизуйтесь, чтобы видеть персональные статусы, документы и уведомления.', + NULL, + NULL, + NULL, + NULL, + NULL, + NULL, + 'active', + NULL, + 'A', + NULL, + NULL, + now(), + now(), + NULL +), + +-- 2. Установка приложения +( + '019fa3e9-8d91-722a-90df-e4824e23b354', + 'install_app', + now(), + 'Установите приложение HAN', + 'Добавьте приложение на главный экран, чтобы сервис всегда был под рукой.', + NULL, + NULL, + NULL, + NULL, + 'https://www.han0107.ru/app/how-install-pwa', + NULL, + 'active', + NULL, + 'A', + NULL, + NULL, + now(), + now(), + NULL +), + +-- 3. Глобальная акция +( + '019fa3e9-8d91-7d19-8873-eefb2a60f116', + 'promo_global', + now(), + 'Специальное предложение', + 'Узнайте подробнее об актуальной акции.', + NULL, + now() + interval '30 days', + NULL, + NULL, + NULL, + 'Здравствуйте! Хочу узнать подробнее об акции.', + 'active', + NULL, + 'A', + NULL, + NULL, + now(), + now(), + NULL +), + +-- 4. Глобальное предложение +( + '019fa3e9-8d91-7452-a13d-3d2518ea257d', + 'ads_global', + now(), + 'Нужна помощь?', + 'Расскажем об услугах и подберём подходящее решение.', + NULL, + now() + interval '30 days', + NULL, + NULL, + NULL, + 'Здравствуйте! Хочу получить консультацию по услугам.', + 'active', + NULL, + 'A', + NULL, + NULL, + now(), + now(), + NULL +) +ON CONFLICT (id) DO UPDATE SET + notification_datetime = EXCLUDED.notification_datetime, + header = EXCLUDED.header, + text = EXCLUDED.text, + priority_override = EXCLUDED.priority_override, + date_expired = EXCLUDED.date_expired, + price = EXCLUDED.price, + old_price = EXCLUDED.old_price, + instruction_url = EXCLUDED.instruction_url, + chat_message_text = EXCLUDED.chat_message_text, + lifecycle_status = 'active', + closed_at = NULL, + record_status = 'A', + status_changed_at = now(), + status_change_reason = 'guest_campaign_republished', + updated_at = now(); + +COMMIT; + +## Персональные + +Запускать из /opt/han-chat/backend. Публичный nginx не пропускает internal API, поэтому используем контейнер в backend-сети. + +read -rsp "NOTIFICATIONS_TOKEN_PRODUCER_TEST: " TOKEN +echo +read -rp "USER_ID клиента: " USER_ID + +NOW=$(date -u +"%Y-%m-%dT%H:%M:%SZ") +EXTERNAL_ID="manual-news-$(date +%s)" + +docker run --rm -i \ + --network han-chat-backend \ + curlimages/curl:latest \ + -sS -i \ + -X POST \ + 'http://api-backend:8000/internal/notifications/v1/notifications' \ + -H "Authorization: Bearer ${TOKEN}" \ + -H 'Content-Type: application/json' \ + --data-binary @- <