Newer
Older
gn-synapse / app / api / me_routes.py
"""Личный кабинет любого аутентифицированного пользователя gnexus-auth.

Роли user (не admin) в админ-часть не пускаются (require_admin), но личное
им доступно: /me — профиль; /me/events — личный лог (канал user);
/me/push* — web-push подписки (channel push, docs/06-settings-pwa.md).

Без ролевого гейта: get_current_user — только аутентификация call-home.
"""

import uuid
from datetime import datetime

from fastapi import Depends, HTTPException, Query, Response, status
from fastapi import APIRouter
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.orm import Session

from app.auth.deps import get_current_user
from app.config import get_settings
from app.database import get_db
from app.api.routes import AdminMe
from app.models import Delivery, Event, PushSubscription, Source
from app.settings_store import get_setting_row

router = APIRouter(prefix="/api/v1", tags=["me"])


class MeEventOut(BaseModel):
    id: uuid.UUID
    source_name: str
    subject: str
    action: str
    priority: str
    payload: dict
    created_at: datetime
    delivered_at: datetime | None
    rendered_message: str | None


class MePushOut(BaseModel):
    """VAPID для подписки браузера; configured=False — пуш ещё не настроен."""

    configured: bool
    public_key: str | None


class PushSubscriptionOut(BaseModel):
    id: int
    # endpoint целиком — не секрет; UI укорачивает для показа сам
    endpoint: str
    ua: str | None
    created_at: datetime


class PushSubscriptionIn(BaseModel):
    endpoint: str = Field(min_length=10, max_length=500)
    keys: dict  # {"p256dh": ..., "auth": ...}
    ua: str | None = Field(None, max_length=255)


@router.get("/me", response_model=AdminMe)
def me(user=Depends(get_current_user)) -> AdminMe:
    """Профиль по токену gnexus-auth (любая роль). SPA-гейт решает по system_role."""
    return AdminMe(
        sub=str(user.user_id),
        email=user.email,
        system_role=user.system_role or "unknown",
        username=user.profile.get("username"),
        display_name=user.profile.get("display_name"),
        avatar_url=user.profile.get("avatar_url"),
        account_url=f"{get_settings().gauth_base_url.rstrip('/')}/account/profile",
    )


@router.get("/me/events", response_model=list[MeEventOut])
def my_events(
    user=Depends(get_current_user),
    db: Session = Depends(get_db),
    limit: int = Query(50, ge=1, le=200),
) -> list[MeEventOut]:
    """События, направленные лично этому пользователю.

    Только доставки канала user его адресата: событие попадает сюда
    исключительно через правило маршрутизации. Строгий фильтр по sub —
    чужое не видно никогда. Дедуп по событию (одно событие могут направить
    два правила — показываем один раз).
    """
    sub = str(user.user_id)
    rows = (
        db.execute(
            select(Delivery, Event, Source.name)
            .join(Event, Event.id == Delivery.event_id)
            .join(Source, Source.id == Event.source_id)
            .where(
                Delivery.channel == "user",
                Delivery.recipient_user_id == sub,
                Delivery.status == "delivered",
            )
            .order_by(Delivery.created_at.desc(), Delivery.id.desc())
            .limit(limit * 2)  # запас на дубли двух правил
        )
        .all()
    )
    seen: set[uuid.UUID] = set()
    out: list[MeEventOut] = []
    for delivery, event, source_name in rows:
        if event.id in seen:
            continue
        seen.add(event.id)
        out.append(
            MeEventOut(
                id=event.id,
                source_name=source_name,
                subject=event.subject,
                action=event.action,
                priority=event.priority,
                payload=dict(event.payload or {}),
                created_at=event.created_at,
                delivered_at=delivery.delivered_at,
                rendered_message=delivery.rendered_message,
            )
        )
        if len(out) >= limit:
            break
    return out

# --- Web-push подписки (канал push; самопользовательская привязка, docs/06) ---

@router.get("/me/push", response_model=MePushOut)
def push_config(user=Depends(get_current_user), db: Session = Depends(get_db)):
    """Public-ключ VAPID для pushManager.subscribe браузера клиента."""
    public = get_setting_row(db, "vapid_public_key")
    return MePushOut(configured=bool(public), public_key=public)


@router.get("/me/push/subscriptions", response_model=list[PushSubscriptionOut])
def my_subscriptions(
    user=Depends(get_current_user), db: Session = Depends(get_db)
) -> list[PushSubscriptionOut]:
    """Подписки текущего пользователя (его браузерные устройства)."""
    sub = str(user.user_id)
    rows = (
        db.execute(
            select(PushSubscription)
            .where(PushSubscription.user_id == sub)
            .order_by(PushSubscription.created_at.desc())
        )
        .scalars()
        .all()
    )
    return [PushSubscriptionOut(id=r.id, endpoint=r.endpoint, ua=r.ua, created_at=r.created_at) for r in rows]


@router.post("/me/push/subscriptions", response_model=PushSubscriptionOut, status_code=201)
def save_subscription(
    body: PushSubscriptionIn,
    user=Depends(get_current_user),
    db: Session = Depends(get_db),
):
    """Регистрация/обновление подписки этого браузера (upsert по endpoint)."""
    keys = body.keys or {}
    p256dh = str(keys.get("p256dh") or "").strip()
    auth = str(keys.get("auth") or "").strip()
    if not p256dh or not auth:
        raise HTTPException(
            status.HTTP_422_UNPROCESSABLE_ENTITY,
            "В подписке нет ключей keys.p256dh/keys.auth от pushManager",
        )
    sub = (
        db.execute(
            select(PushSubscription).where(PushSubscription.endpoint == body.endpoint)
        )
        .scalars()
        .first()
    )
    if sub is None:
        sub = PushSubscription(endpoint=body.endpoint, p256dh=p256dh, auth=auth, ua=body.ua,
                               user_id=str(user.user_id))
        db.add(sub)
    else:
        # endpoint уникален: могла остаться под прошлым владельцем (браузер тот же,
        # а пользователь в нём другой) — теперь подписка принадлежит этому юзеру.
        sub.user_id = str(user.user_id)
        sub.p256dh, sub.auth, sub.ua = p256dh, auth, body.ua
    db.commit()
    db.refresh(sub)
    return PushSubscriptionOut(id=sub.id, endpoint=sub.endpoint, ua=sub.ua, created_at=sub.created_at)


@router.delete("/me/push/subscriptions/{sub_id}", status_code=204)
def remove_subscription(
    sub_id: int,
    user=Depends(get_current_user),
    db: Session = Depends(get_db),
):
    """Отписать свой (user_id совпадает) endpoint; чужой — 404."""
    sub = db.get(PushSubscription, sub_id)
    if sub is None or sub.user_id != str(user.user_id):
        raise HTTPException(status.HTTP_404_NOT_FOUND, "Подписка не найдена")
    db.delete(sub)
    db.commit()
    return Response(status_code=204)