"""API системных уведомлений (ТЗ 3.21).
Подписка — свойство устройства, а не пользователя: один человек приходит и с
телефона, и с ноутбука, и каждому нужен свой адрес доставки. Поэтому подписок
может быть много, а `endpoint` уникален глобально (в браузерном профиле она одна)
— при входе другим аккаунтом подписка перепривязывается, а не размножается.
`publish` здесь не нужен: подписка не меняет то, что видит открытая вкладка.
"""
from fastapi import APIRouter, HTTPException, Request
from sqlalchemy import select
from app.config import get_settings
from app.dependencies import DbDep, UserIdDep
from app.models import PushSubscription
from app.schemas import (
PushConfigOut,
PushSubscribeIn,
PushSubscriptionOut,
PushUnsubscribeIn,
)
from app.services.push import delete_subscription, save_subscription
router = APIRouter(prefix="/api/push", tags=["push"])
def _out(sub: PushSubscription) -> PushSubscriptionOut:
return PushSubscriptionOut.model_validate(sub)
@router.get("/config", response_model=PushConfigOut)
async def get_config(_user_id: UserIdDep) -> PushConfigOut:
"""Состояние push на сервере: без ключей VAPID подписываться некуда, и фронт
показывает блок настроек выключенным, а не падает на подписке."""
settings = get_settings()
return PushConfigOut(
enabled=settings.push_enabled,
public_key=settings.vapid_public_key or None,
)
@router.post("/subscribe", response_model=PushSubscriptionOut)
async def subscribe(
schema: PushSubscribeIn, request: Request, db: DbDep, user_id: UserIdDep
) -> PushSubscriptionOut:
"""Сохранить (или перепривязать) подписку устройства."""
if not get_settings().push_enabled:
raise HTTPException(status_code=503, detail="Push is not configured")
sub = save_subscription(
db,
user_id,
schema.endpoint,
schema.keys.p256dh,
schema.keys.auth,
request.headers.get("user-agent"),
)
db.commit()
return _out(sub)
@router.delete("/subscribe")
async def unsubscribe(
schema: PushUnsubscribeIn, db: DbDep, user_id: UserIdDep
) -> dict[str, bool]:
"""Отписаться идемпотентно: браузер мог уже потерять подписку сам."""
delete_subscription(db, user_id, schema.endpoint)
db.commit()
# {ok: true}, а не 204: у фронта один разбор ответа (res.json()), и пустое
# тело ломало бы его — так же отвечает удаление MCP-токена
return {"ok": True}
@router.get("/subscriptions", response_model=list[PushSubscriptionOut])
async def list_subscriptions(db: DbDep, user_id: UserIdDep) -> list[PushSubscriptionOut]:
"""Устройства пользователя — для строки «подключено устройств: N» в настройках."""
rows = db.scalars(
select(PushSubscription)
.where(PushSubscription.user_id == user_id)
.order_by(PushSubscription.created_at)
).all()
return [_out(row) for row in rows]
@router.delete("/subscriptions/{sub_id}")
async def delete_device(sub_id: int, db: DbDep, user_id: UserIdDep) -> dict[str, bool]:
"""Убрать конкретное устройство. Чужую подписку не отдаём и не удаляем —
отвечаем 404, чтобы по коду нельзя было даже узнать о её существовании."""
sub = db.get(PushSubscription, sub_id)
if sub is None or sub.user_id != user_id:
raise HTTPException(status_code=404, detail="Subscription not found")
db.delete(sub)
db.commit()
return {"ok": True}