Files
WealthySmart/backend/app/api/v1/endpoints/notifications.py
Carlos Escalante 7dfe2da2a9 OpenAPI type codegen with CI drift gate (plan 3.4, ARCH-16, FE-14)
Every dict-returning route the SPA consumes now declares a response
model (auth, bulk, sync-status, notifications). api-types.gen.ts is
generated from app.openapi() via openapi-typescript, and the 22 hand-
written read interfaces in api.ts are now aliases onto the generated
schemas — backend schema drift becomes a typecheck failure, and CI
regenerates the file and fails the deploy if it's stale. The aliasing
immediately caught two real drifts: RecurringItemType was missing
SAVINGS, and raw_charges (untyped JSON column) is now explicitly
shaped at the boundary.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 07:53:00 -06:00

103 lines
3.3 KiB
Python

import json
import logging
from pydantic import BaseModel
from fastapi import APIRouter, Depends, HTTPException
from pywebpush import WebPushException, webpush
from sqlmodel import Session, select
from app.auth import get_current_user
from app.config import settings
from app.db import get_session
from app.models.models import PushSubscription, PushSubscriptionCreate
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/notifications", tags=["notifications"])
class VapidPublicKeyResponse(BaseModel):
publicKey: str
class SubscriptionStatusResponse(BaseModel):
status: str
@router.get("/vapid-public-key", response_model=VapidPublicKeyResponse)
def get_vapid_public_key(_user: str = Depends(get_current_user)):
if not settings.VAPID_PUBLIC_KEY:
raise HTTPException(status_code=503, detail="Push notifications not configured")
return {"publicKey": settings.VAPID_PUBLIC_KEY}
@router.post("/subscribe", status_code=201, response_model=SubscriptionStatusResponse)
def subscribe(
data: PushSubscriptionCreate,
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
existing = session.exec(
select(PushSubscription).where(PushSubscription.endpoint == data.endpoint)
).first()
if existing:
existing.p256dh = data.keys["p256dh"]
existing.auth = data.keys["auth"]
session.add(existing)
session.commit()
return {"status": "updated"}
sub = PushSubscription(
endpoint=data.endpoint,
p256dh=data.keys["p256dh"],
auth=data.keys["auth"],
)
session.add(sub)
session.commit()
return {"status": "subscribed"}
@router.delete("/unsubscribe", response_model=SubscriptionStatusResponse)
def unsubscribe(
data: PushSubscriptionCreate,
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
existing = session.exec(
select(PushSubscription).where(PushSubscription.endpoint == data.endpoint)
).first()
if existing:
session.delete(existing)
session.commit()
return {"status": "unsubscribed"}
def send_push_to_all(session: Session, title: str, body: str, url: str = "/"):
"""Send a push notification to all registered subscriptions."""
if not settings.VAPID_PRIVATE_KEY or not settings.VAPID_PUBLIC_KEY:
logger.debug("VAPID keys not configured, skipping push notification")
return
subscriptions = session.exec(select(PushSubscription)).all()
payload = json.dumps({"title": title, "body": body, "url": url})
for sub in subscriptions:
subscription_info = {
"endpoint": sub.endpoint,
"keys": {"p256dh": sub.p256dh, "auth": sub.auth},
}
try:
webpush(
subscription_info=subscription_info,
data=payload,
vapid_private_key=settings.VAPID_PRIVATE_KEY,
vapid_claims={"sub": settings.VAPID_CLAIM_EMAIL},
)
except WebPushException as e:
logger.warning("Push failed for %s: %s", sub.endpoint[:50], e)
if e.response and e.response.status_code in (404, 410):
session.delete(sub)
session.commit()
except Exception:
logger.exception("Unexpected push error for %s", sub.endpoint[:50])