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