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

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