from __future__ import annotations from typing import Literal from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy.orm import Session from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope from govoplan_core.db.session import get_session from govoplan_notifications.backend.manifest import ADMIN_SCOPE, DISPATCH_SCOPE, READ_SCOPE, WRITE_SCOPE from govoplan_notifications.backend.schemas import ( NotificationCreateRequest, NotificationDeliverPendingRequest, NotificationDeliveryResultResponse, NotificationListResponse, NotificationPreferencesResponse, NotificationPreferencesUpdateRequest, NotificationResponse, NotificationSummaryResponse, NotificationUpdateRequest, ) from govoplan_notifications.backend.service import ( NotificationError, create_notification, deliver_notification, deliver_pending, get_notification, get_notification_preferences, list_notifications, notification_preferences_response, notification_response, notification_summary, update_notification_preferences, update_notification, ) router = APIRouter(prefix="/notifications", tags=["notifications"]) def _require_scope(principal: ApiPrincipal, scope: str) -> None: if not has_scope(principal, scope): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"Missing scope: {scope}") def _notification_http_error(exc: NotificationError) -> HTTPException: if str(exc) == "Notification not found": return HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) return HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) def _response(notification) -> NotificationResponse: return NotificationResponse.model_validate(notification_response(notification)) def _principal_recipient_ids(principal: ApiPrincipal) -> tuple[str, ...]: """Return the stable actor identifiers accepted for personal notifications. Existing producers use both tenant membership/user ids and account ids. Both identify the same authenticated actor; identity/service-account ids are included when present so producers can address those stable identities too. """ candidates = ( getattr(principal.user, "id", None), principal.membership_id, principal.account_id, principal.identity_id, principal.principal.service_account_id, ) return tuple(dict.fromkeys(str(value) for value in candidates if value)) def _recipient_ids_for_view(principal: ApiPrincipal, view: Literal["personal", "tenant"]) -> tuple[str, ...] | None: if view == "tenant": _require_scope(principal, ADMIN_SCOPE) return None return _principal_recipient_ids(principal) @router.get("", response_model=NotificationListResponse) def api_list_notifications( status_filter: str | None = Query(default=None, alias="status"), channel: str | None = None, source_module: str | None = None, recipient_id: str | None = None, view: Literal["personal", "tenant"] = Query(default="personal"), limit: int = Query(default=100, ge=1, le=500), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> NotificationListResponse: _require_scope(principal, READ_SCOPE) recipient_ids = _recipient_ids_for_view(principal, view) if recipient_id is not None and recipient_ids is not None and recipient_id not in recipient_ids: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Cannot read another recipient's notifications") notifications = list_notifications( session, tenant_id=principal.tenant_id, status=status_filter, channel=channel, source_module=source_module, recipient_id=recipient_id, recipient_ids=recipient_ids, limit=limit, ) return NotificationListResponse(notifications=[_response(notification) for notification in notifications]) @router.get("/summary", response_model=NotificationSummaryResponse) def api_notification_summary( view: Literal["personal", "tenant"] = Query(default="personal"), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> NotificationSummaryResponse: _require_scope(principal, READ_SCOPE) return NotificationSummaryResponse.model_validate( notification_summary( session, tenant_id=principal.tenant_id, user_id=principal.user.id, recipient_ids=_recipient_ids_for_view(principal, view), ) ) @router.get("/preferences/me", response_model=NotificationPreferencesResponse) def api_get_my_notification_preferences( session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> NotificationPreferencesResponse: _require_scope(principal, READ_SCOPE) preference = get_notification_preferences(session, tenant_id=principal.tenant_id, user_id=principal.user.id) return NotificationPreferencesResponse.model_validate(notification_preferences_response(preference)) @router.put("/preferences/me", response_model=NotificationPreferencesResponse) def api_update_my_notification_preferences( payload: NotificationPreferencesUpdateRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> NotificationPreferencesResponse: _require_scope(principal, WRITE_SCOPE) preference = update_notification_preferences(session, tenant_id=principal.tenant_id, user_id=principal.user.id, payload=payload) session.commit() return NotificationPreferencesResponse.model_validate(notification_preferences_response(preference)) @router.post("", response_model=NotificationResponse, status_code=status.HTTP_201_CREATED) def api_create_notification( payload: NotificationCreateRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> NotificationResponse: _require_scope(principal, WRITE_SCOPE) try: notification = create_notification(session, tenant_id=principal.tenant_id, payload=payload) except NotificationError as exc: raise _notification_http_error(exc) from exc session.commit() return _response(notification) @router.post("/deliver-pending", response_model=NotificationDeliveryResultResponse) def api_deliver_pending( payload: NotificationDeliverPendingRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> NotificationDeliveryResultResponse: _require_scope(principal, DISPATCH_SCOPE) if payload.tenant_id is not None: _require_scope(principal, ADMIN_SCOPE) result = deliver_pending(session, tenant_id=payload.tenant_id or principal.tenant_id, limit=payload.limit) session.commit() return NotificationDeliveryResultResponse.model_validate(result) @router.get("/{notification_id}", response_model=NotificationResponse) def api_get_notification( notification_id: str, view: Literal["personal", "tenant"] = Query(default="personal"), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> NotificationResponse: _require_scope(principal, READ_SCOPE) try: return _response( get_notification( session, tenant_id=principal.tenant_id, notification_id=notification_id, recipient_ids=_recipient_ids_for_view(principal, view), ) ) except NotificationError as exc: raise _notification_http_error(exc) from exc @router.patch("/{notification_id}", response_model=NotificationResponse) def api_update_notification( notification_id: str, payload: NotificationUpdateRequest, view: Literal["personal", "tenant"] = Query(default="personal"), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> NotificationResponse: _require_scope(principal, WRITE_SCOPE) try: notification = update_notification( session, tenant_id=principal.tenant_id, notification_id=notification_id, payload=payload, recipient_ids=_recipient_ids_for_view(principal, view), ) except NotificationError as exc: raise _notification_http_error(exc) from exc session.commit() return _response(notification) @router.post("/{notification_id}/deliver", response_model=NotificationDeliveryResultResponse) def api_deliver_notification( notification_id: str, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> NotificationDeliveryResultResponse: _require_scope(principal, DISPATCH_SCOPE) try: get_notification(session, tenant_id=principal.tenant_id, notification_id=notification_id) notification = deliver_notification(session, notification_id=notification_id) except NotificationError as exc: raise _notification_http_error(exc) from exc session.commit() sent = 1 if notification.status == "sent" else 0 failed = 1 if notification.status == "failed" else 0 skipped = 1 if notification.status == "skipped" else 0 return NotificationDeliveryResultResponse( notification=_response(notification), processed=1, sent=sent, failed=failed, skipped=skipped, errors=[notification.last_error] if notification.last_error else [], )