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

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
@@ -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())